diff --git a/.cursorrules b/.cursorrules index 45cdc9c..20285df 100644 --- a/.cursorrules +++ b/.cursorrules @@ -23,4 +23,141 @@ - 1. 손절(Stop-loss) 및 예외 처리 로직이 포함되었는가? - 2. API 호출 제한(429 Error) 및 슬리피지 고려가 되었는가? - 3. 사용자가 요청한 기존 로직과 100% 동일한 기능을 수행하는가? -- 만약 하나라도 빠졌다면 코드를 출력하기 전에 스스로 수정하세요. \ No newline at end of file +- 만약 하나라도 빠졌다면 코드를 출력하기 전에 스스로 수정하세요. + +# [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: + +## 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 tag. + +\ +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. + + +\ +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. + + +\ +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. + + +\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ +\{"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"}}\ + + +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. + + +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. + + +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) 등은 절대 지우지 않고 그대로 유지한다. \ No newline at end of file diff --git a/.gitignore b/.gitignore index 0c53104..a60e604 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +.env +# Environment variables +.env +*.log +*.json +*.db +.aider* diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..c3f502a --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml +# 에디터 기반 HTTP 클라이언트 요청 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/kis_bot.iml b/.idea/kis_bot.iml new file mode 100644 index 0000000..37901ac --- /dev/null +++ b/.idea/kis_bot.iml @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..73bce2a --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/CONVENTIONS.md b/CONVENTIONS.md new file mode 100644 index 0000000..25c6769 --- /dev/null +++ b/CONVENTIONS.md @@ -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)기존 로직과의 기능 동일성을 스스로 검토한 후 최종 결과를 한국어로 출력한다. \ No newline at end of file diff --git a/KIS_TOKEN_SMS.md b/KIS_TOKEN_SMS.md new file mode 100644 index 0000000..1e60c39 --- /dev/null +++ b/KIS_TOKEN_SMS.md @@ -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 발급과 무관. diff --git a/README_ETF.md b/README_ETF.md new file mode 100644 index 0000000..fd01a82 --- /dev/null +++ b/README_ETF.md @@ -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 투자 되세요!** diff --git a/TAIL_BACKTEST_VS_LIVE.md b/TAIL_BACKTEST_VS_LIVE.md new file mode 100644 index 0000000..b0a6873 --- /dev/null +++ b/TAIL_BACKTEST_VS_LIVE.md @@ -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와 동일 구조이되, **매수/매도 판단만 엔진 호출**로 통일하면 백테와 결과를 맞추기 쉬움. diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index e4fcaa4..51be279 100644 Binary files a/__pycache__/database.cpython-312.pyc and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/ml_predictor.cpython-312.pyc b/__pycache__/ml_predictor.cpython-312.pyc index 4d87d7f..63861ef 100644 Binary files a/__pycache__/ml_predictor.cpython-312.pyc and b/__pycache__/ml_predictor.cpython-312.pyc differ diff --git a/__pycache__/risk_manager.cpython-312.pyc b/__pycache__/risk_manager.cpython-312.pyc index 4f1530b..58f27c5 100644 Binary files a/__pycache__/risk_manager.cpython-312.pyc and b/__pycache__/risk_manager.cpython-312.pyc differ diff --git a/ai_recommend_notice.py b/ai_recommend_notice.py new file mode 100644 index 0000000..53acdfe --- /dev/null +++ b/ai_recommend_notice.py @@ -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() diff --git a/back_test.py b/back_test.py new file mode 100644 index 0000000..821fbbd --- /dev/null +++ b/back_test.py @@ -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() diff --git a/backtest_scalping/holding_param_search.py b/backtest_scalping/holding_param_search.py new file mode 100644 index 0000000..d9c7057 --- /dev/null +++ b/backtest_scalping/holding_param_search.py @@ -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() diff --git a/backtest_scalping/param_search.py b/backtest_scalping/param_search.py new file mode 100644 index 0000000..7ca9c8d --- /dev/null +++ b/backtest_scalping/param_search.py @@ -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() diff --git a/backtest_scalping/tail_param_search.py b/backtest_scalping/tail_param_search.py new file mode 100644 index 0000000..0f655b7 --- /dev/null +++ b/backtest_scalping/tail_param_search.py @@ -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() diff --git a/backtest_web.py b/backtest_web.py index 3583ab7..4f16c70 100644 --- a/backtest_web.py +++ b/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""" +
+ + +
+
+ + +
+
+ + +
+
+ + +
@@ -1985,7 +1965,7 @@ HTML_TEMPLATE = r"""
- +
@@ -2007,7 +1987,7 @@ HTML_TEMPLATE = r"""
- 📖 파라미터 설명 (실제 봇 kis_short_ver2.py 기준) + 📖 파라미터 설명 (실매·파라서치와 동일 DB env_config — 화면 값 = 백테에 사용되는 값)
@@ -2022,12 +2002,16 @@ HTML_TEMPLATE = r"""
+ + + - +
어깨발동(%)수익이 이 값 이상일 때 어깨 컷 활성 → 봇 DB: SHOULDER_MIN_HIGH_PCT=1.5%
어깨컷(%)고점 대비 이 값 하락 시 매도 → 봇 DB: SHOULDER_CUT_PCT=3%
꼬리최소(%)꼬리 길이 최소 비율 → 봇 DB: TAIL_PCT_MIN
3분최대회복(%)3분봉 회복위치 상한 → 봇 DB: MAX_RECOVERY_RATIO_3M
고점추격방지(%)고점 대비 이 비율 이상이면 진입 거부 → 봇 DB: HIGH_PRICE_CHASE_THRESHOLD
RSI과열기준RSI 이상이면 진입 거부 → 봇 DB: RSI_OVERHEAT_THRESHOLD=78
쿨다운(분)청산 후 재진입 금지 → 봇에는 없음(백테스트 전용)
쿨다운(분)청산 후 재진입 금지 → 봇 DB: REENTRY_COOLDOWN_SEC (초→분)
⚡ 진입가 = 신호봉(3분봉) 다음 봉 시가 | 수수료 0.015%×2 + 거래세 0.18% 자동 차감 | 데이터: ws_candles 3분봉
+
📌 웹 백테와 파라미터서치 결과를 비교하려면 시작일/종료일매수시작/매수종료를 동일하게 두세요. 파라서치는 DB 기준(예: 930–1500)을 쓰므로, 비교 시 웹에서도 0930·1500으로 맞추면 거래 수·손익이 비슷해집니다.
@@ -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) + '%'; diff --git a/database.py b/database.py index a64129e..8875319 100644 --- a/database.py +++ b/database.py @@ -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", diff --git a/docs/CANDLE_FLOW.md b/docs/CANDLE_FLOW.md new file mode 100644 index 0000000..db566a5 --- /dev/null +++ b/docs/CANDLE_FLOW.md @@ -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는 비동기로 쌓인다. diff --git a/etf_backtest.py b/etf_backtest.py new file mode 100644 index 0000000..e19bb5d --- /dev/null +++ b/etf_backtest.py @@ -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"🔔 [ETF 액티브 봇] {trade_info['type']}\n\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📊 [야후 파이낸스 차트 확인]" + + 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" + ) diff --git a/etf_ver1.py b/etf_ver1.py new file mode 100644 index 0000000..457192a --- /dev/null +++ b/etf_ver1.py @@ -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"[ETF 액티브] {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() diff --git a/export_sniper.py b/export_sniper.py new file mode 100644 index 0000000..de2ff8b --- /dev/null +++ b/export_sniper.py @@ -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 diff --git a/fetch_stock_meta.py b/fetch_stock_meta.py new file mode 100644 index 0000000..fb2284b --- /dev/null +++ b/fetch_stock_meta.py @@ -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, + ) diff --git a/holding_bot.py b/holding_bot.py new file mode 100644 index 0000000..c1b6c4d --- /dev/null +++ b/holding_bot.py @@ -0,0 +1,2186 @@ +#!/usr/bin/env python3 +""" +holding_bot.py — 장기 홀딩 전략 봇 (추세추종 + 눌림목 분할매수) +======================================================================= +전략 개요: + [추세 상승: MA20 > MA60] + 매수: RSI 눌림목(rsi_buy1/2) 시 분할 매수 — 기준 완화(추세장 특성) + 매도: 트레일링 스탑(고점 대비 trail_stop_pct% 하락) OR RSI 과열(rsi_sell) + OR 데드크로스(MA20 < MA60) 발생 시 추세 반전 청산 + + [추세 하락/횡보: MA20 ≤ MA60] (trend_filter=0 이면 전 구간 적용) + 매수: 기존 RSI 3단계 역추세 분할매수 (rsi_buy1/2/3) + 매도: 익절(take_profit_pct) OR RSI 과열 OR 손절(stop_loss_pct) + + 봉: 일봉(D) 기준, KIS API로 최대 100봉씩 페이지네이션 수집 + +DB 테이블: + holding_candles — 종목별 일봉 OHLCV (별도 관리) + holding_stock_config — 종목별 파라미터 (code별 최신 INSERT 방식) + +실행: + python3 holding_bot.py +""" + +import os, sys, time, json, logging, random +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Dict, Optional, Tuple + +import requests + +ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) + +from database import TradeDB + +logging.basicConfig( + format="[%(asctime)s] %(message)s", + datefmt="%H:%M:%S", + level=logging.INFO, +) +logger = logging.getLogger("HoldingBot") +logging.getLogger("TradeDB").setLevel(logging.WARNING) + +# 관심종목 파일 +WATCHLIST_PATH = ROOT / "long_term_watchlist.json" + +# ───────────────────────────────────────────────────────────────────────────── +# 기본 파라미터 (종목별 DB에 없으면 이 값 사용) +# ───────────────────────────────────────────────────────────────────────────── +DEFAULT_STOCK_CONFIG = { + "rsi_period": 14, # RSI 계산 기간 + "rsi_buy1": 55.0, # 1차 매수 RSI ≤ (추세장: 50~55 / 횡보장: 45 이하) + "rsi_buy2": 45.0, # 2차 매수 RSI ≤ + "rsi_buy3": 35.0, # 3차 매수 RSI ≤ (횡보/하락장 전용) + "rsi_sell": 75.0, # RSI 과열 매도 기준 (추세장에선 80 수준으로 올려 조기매도 방지) + "take_profit_pct": 15.0, # 수익률 익절 기준 (%) — 추세장에선 크게 잡아야 탈 안 남 + "stop_loss_pct": 10.0, # 손절 기준 (%) — 양수로 저장, 음수 방향 적용 + "buy1_ratio": 50.0, # 1차 투자 비중 (%) — 추세장엔 처음부터 크게 + "buy2_ratio": 50.0, # 2차 투자 비중 (%) + "buy3_ratio": 0.0, # 3차 투자 비중 (%) — 추세장에선 0 (자금 보류 안 함) + "slot_money": 3_000_000, # 종목당 총 투자금 (원) + "atr_period": 14, # ATR 계산 기간 (True Range의 지수이동평균) + # ── 샹들리에 엑시트 (Chandelier Exit) ───────────────────────────── + # 추세장 청산에서 사용: 고점 − ATR × atr_mult → 변동성 기반 동적 방어선 + # mult=2: 타이트(빠른 청산) / mult=3: 기본 / mult=4: 여유롭게(더 오래 홀딩) + "atr_mult": 3.0, # 샹들리에 엑시트 배수 (ATR_MULT) + # ── 추세추종 신규 파라미터 ────────────────────────────────────── + # MA 골든/데드크로스 기반 추세 판단 + "ma_fast": 20.0, # 단기 이동평균 기간 (일봉 기준 약 1달) + "ma_slow": 60.0, # 장기 이동평균 기간 (일봉 기준 약 3달) + # 트레일링 스탑: 포지션 보유 중 고점 대비 이 비율 이상 하락 시 청산 (0=비활성) + # 예) 8.0 → 고점 대비 -8% 하락하면 추세 이탈로 판단해 청산 + "trail_stop_pct": 8.0, + # 추세 필터 사용 여부 (1=사용, 0=미사용 → 기존 역추세 방식으로 고정) + "trend_filter": 1.0, + # ── 컨텍스트 낙폭 필터 (0=비활성화) ────────────────────────── + "ath_drop_min_pct": 0.0, + "year_drop_min_pct": 0.0, + "w52_drop_min_pct": 0.0, +} + +# ───────────────────────────────────────────────────────────────────────────── +# 관심종목 로드 +# ───────────────────────────────────────────────────────────────────────────── +def load_watchlist() -> List[Dict]: + try: + with open(WATCHLIST_PATH, encoding="utf-8") as f: + data = json.load(f) + return data.get("items", []) + except Exception as e: + logger.error(f"관심종목 파일 로드 실패: {e}") + return [] + +# ───────────────────────────────────────────────────────────────────────────── +# DB 테이블 생성 (holding 전용) +# ───────────────────────────────────────────────────────────────────────────── +HOLDING_DDL = [ + """ + CREATE TABLE IF NOT EXISTS holding_min_candles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(10) NOT NULL, + candle_dt DATETIME NOT NULL COMMENT '60분봉 기준 시작 시간(KST)', + tf SMALLINT NOT NULL DEFAULT 60 COMMENT '분봉 단위 (60=60분봉)', + open FLOAT NOT NULL DEFAULT 0, + high FLOAT NOT NULL DEFAULT 0, + low FLOAT NOT NULL DEFAULT 0, + close FLOAT NOT NULL DEFAULT 0, + volume BIGINT NOT NULL DEFAULT 0, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_holding_min (code, tf, candle_dt) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + """ + CREATE TABLE IF NOT EXISTS holding_candles ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(10) NOT NULL, + candle_date DATE NOT NULL, + open FLOAT NOT NULL DEFAULT 0, + high FLOAT NOT NULL DEFAULT 0, + low FLOAT NOT NULL DEFAULT 0, + close FLOAT NOT NULL DEFAULT 0, + volume BIGINT NOT NULL DEFAULT 0, + change_rate FLOAT NOT NULL DEFAULT 0 COMMENT '전일대비등락률(%%)', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_holding_candle (code, candle_date) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, + """ + CREATE TABLE IF NOT EXISTS holding_stock_config ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(10) NOT NULL, + name VARCHAR(50) DEFAULT '', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + rsi_period FLOAT DEFAULT 14, + rsi_buy1 FLOAT DEFAULT 45, + rsi_buy2 FLOAT DEFAULT 40, + rsi_buy3 FLOAT DEFAULT 35, + rsi_sell FLOAT DEFAULT 70, + take_profit_pct FLOAT DEFAULT 5.0, + stop_loss_pct FLOAT DEFAULT 10.0, + buy1_ratio FLOAT DEFAULT 30, + buy2_ratio FLOAT DEFAULT 30, + buy3_ratio FLOAT DEFAULT 40, + slot_money FLOAT DEFAULT 3000000, + atr_period FLOAT DEFAULT 14, + ath_drop_min_pct FLOAT DEFAULT 0 COMMENT 'ATH 대비 최소 낙폭%% (0=비활성)', + year_drop_min_pct FLOAT DEFAULT 0 COMMENT '연도고점 대비 최소 낙폭%% (0=비활성)', + w52_drop_min_pct FLOAT DEFAULT 0 COMMENT '52주고점 대비 최소 낙폭%% (0=비활성)', + ma_fast FLOAT DEFAULT 20 COMMENT '단기 이동평균 기간', + ma_slow FLOAT DEFAULT 60 COMMENT '장기 이동평균 기간', + trail_stop_pct FLOAT DEFAULT 8 COMMENT '트레일링 스탑 %% (0=비활성)', + trend_filter FLOAT DEFAULT 1 COMMENT '추세 필터 (1=사용/0=미사용)', + KEY idx_holding_config_code (code) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """, +] + +def ensure_holding_tables(db: TradeDB): + """holding 전용 테이블 없으면 생성, 기존 테이블에 신규 컬럼 추가(마이그레이션)""" + for ddl in HOLDING_DDL: + db.conn.execute(ddl.strip()) + db.conn.commit() + + # ── 기존 테이블에 신규 컬럼 추가 (이미 있으면 무시) ───────────── + migrations = [ + # holding_candles: change_rate 추가 + "ALTER TABLE holding_candles ADD COLUMN change_rate FLOAT NOT NULL DEFAULT 0 COMMENT '전일대비등락률(%%)'", + # holding_stock_config: 낙폭 필터 3종 추가 + "ALTER TABLE holding_stock_config ADD COLUMN ath_drop_min_pct FLOAT DEFAULT 0 COMMENT 'ATH 대비 최소 낙폭%% (0=비활성)'", + "ALTER TABLE holding_stock_config ADD COLUMN year_drop_min_pct FLOAT DEFAULT 0 COMMENT '연도고점 대비 최소 낙폭%% (0=비활성)'", + "ALTER TABLE holding_stock_config ADD COLUMN w52_drop_min_pct FLOAT DEFAULT 0 COMMENT '52주고점 대비 최소 낙폭%% (0=비활성)'", + # 추세추종 신규 파라미터 + "ALTER TABLE holding_stock_config ADD COLUMN ma_fast FLOAT DEFAULT 20 COMMENT '단기 이동평균 기간'", + "ALTER TABLE holding_stock_config ADD COLUMN ma_slow FLOAT DEFAULT 60 COMMENT '장기 이동평균 기간'", + "ALTER TABLE holding_stock_config ADD COLUMN trail_stop_pct FLOAT DEFAULT 8 COMMENT '트레일링 스탑 %% (0=비활성)'", + "ALTER TABLE holding_stock_config ADD COLUMN trend_filter FLOAT DEFAULT 1 COMMENT '추세 필터 (1=사용/0=미사용)'", + ] + for sql in migrations: + try: + db.conn.execute(sql) + db.conn.commit() + except Exception: + pass # 이미 컬럼 존재 → 무시 + + logger.info("✅ holding_candles / holding_stock_config 테이블 확인 완료") + +# ───────────────────────────────────────────────────────────────────────────── +# 종목별 파라미터 관리 +# ───────────────────────────────────────────────────────────────────────────── +def get_stock_config(db: TradeDB, code: str) -> Dict: + """종목별 최신 파라미터 조회 (없으면 DEFAULT 반환)""" + row = db.conn.execute( + "SELECT * FROM holding_stock_config WHERE code=%s ORDER BY id DESC LIMIT 1", + [code] + ).fetchone() + if row: + cfg = dict(row) + cfg.pop("id", None) + cfg.pop("created_at", None) + return cfg + # 기본값 반환 (DB에 없으면) + cfg = dict(DEFAULT_STOCK_CONFIG) + cfg["code"] = code + cfg["name"] = "" + return cfg + +def set_stock_config(db: TradeDB, code: str, name: str, params: Dict): + """종목별 파라미터 저장 (INSERT 신규 row = 최신값 추가 방식)""" + cols = ["code", "name"] + vals = [code, name] + for k, v in params.items(): + if k in DEFAULT_STOCK_CONFIG: + cols.append(k) + vals.append(float(v)) + col_sql = ", ".join(cols) + ph_sql = ", ".join(["%s"] * len(vals)) + db.conn.execute( + f"INSERT INTO holding_stock_config ({col_sql}) VALUES ({ph_sql})", + vals + ) + db.conn.commit() + +# ───────────────────────────────────────────────────────────────────────────── +# KIS API — 일봉 OHLCV 조회 (최대 100봉씩 페이지네이션) +# ───────────────────────────────────────────────────────────────────────────── +def _get_kis_token(db: TradeDB) -> Tuple[str, str, str, bool]: + """ + env_config에서 KIS 인증 정보 조회. + KIS_MOCK=true → 모의 키/도메인 사용 (시세 조회도 모의 도메인에서 동작) + KIS_MOCK=false → 실전 키/도메인 사용 + None 값은 빈 문자열로 안전 처리. + """ + row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone() + if not row: + raise RuntimeError("env_config 테이블이 비어 있습니다.") + r = dict(row) + + def safe(val): + return val if val else "" + + is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes") + + if is_mock: + app_key = safe(r.get("KIS_APP_KEY_MOCK")) + app_secret = safe(r.get("KIS_APP_SECRET_MOCK")) + base_url = "https://openapivts.koreainvestment.com:29443" + else: + app_key = safe(r.get("KIS_APP_KEY_REAL")) or safe(r.get("KIS_APP_KEY")) + app_secret = safe(r.get("KIS_APP_SECRET_REAL")) or safe(r.get("KIS_APP_SECRET")) + base_url = "https://openapi.koreainvestment.com:9443" + + if not app_key or not app_secret: + raise RuntimeError( + f"API 키 미설정 (KIS_MOCK={is_mock})\n" + f"필요 키: {'KIS_APP_KEY_MOCK / KIS_APP_SECRET_MOCK' if is_mock else 'KIS_APP_KEY_REAL / KIS_APP_SECRET_REAL'}" + ) + + return app_key, app_secret, base_url, is_mock + +def _ensure_token(mock: bool) -> str: + """ + KisTokenManager 싱글톤으로 토큰 반환. + - 유효하면 메모리 캐시 즉시 반환 + - 만료 10분 전 선제 갱신 후 반환 + - KIS 23시간 정책 자동 준수 + """ + try: + from kis_token_manager import KisTokenManager + token = KisTokenManager.instance(is_mock=mock).get_token() + if token: + logger.info("🔑 KIS 토큰 준비 완료 [%s] (앞8자: %s…)", + "모의" if mock else "실전", token[:8]) + return token + except Exception as e: + logger.warning("KisTokenManager 실패, 파일 폴백: %s", e) + + # 폴백: 직접 파일 읽기 + path = ROOT / (".kis_token_cache_mock.json" if mock else ".kis_token_cache_real.json") + if not path.exists(): + raise RuntimeError( + f"토큰 캐시 파일 없음: {path}\n" + "kis_short_ver2.py 또는 kis_scalping_ver1.py 를 한 번 실행하면 자동 생성됩니다." + ) + try: + cache = json.loads(path.read_text(encoding="utf-8")) + except Exception as e: + raise RuntimeError(f"토큰 캐시 파일 읽기 실패: {e}") + + token = cache.get("access_token", "") + if not token: + raise RuntimeError("토큰 캐시 파일에 access_token 없음") + logger.info("🔑 KIS 토큰 파일 폴백 재사용 [%s] (앞8자: %s…)", + "모의" if mock else "실전", token[:8]) + return token + +def fetch_daily_ohlcv( + code: str, + start_date: str, # "YYYY-MM-DD" + end_date: str, # "YYYY-MM-DD" + app_key: str, + app_secret: str, + base_url: str, + mock: bool = False, + max_retries: int = 3, +) -> List[Dict]: + """ + KIS API 일봉 OHLCV 조회 (날짜 범위, 100봉씩 자동 페이지네이션) + - 토큰은 기존 .kis_token_cache_real/mock.json 캐시 파일 재사용 (새 발급 안 함) + 반환: [{"date": "YYYYMMDD", "open": float, "high": float, "low": float, + "close": float, "volume": int}, ...] 최신 → 과거 순 + """ + token = _ensure_token(mock) # 캐시 파일에서 읽기만 + url = f"{base_url}/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + + # 날짜 형식 통일 YYYYMMDD + sd = start_date.replace("-", "") + ed = end_date.replace("-", "") + + all_rows: List[Dict] = [] + cursor_end = ed # 첫 호출은 end_date부터 과거 방향 조회 + + for attempt_page in range(100): # 최대 ~10,000봉 + # 페이지당 ~140일 윈도우 (약 100 거래일) + # DATE_1을 cursor_end 기준 180일 전으로 설정해야 API가 정상 응답 + window_start = ( + datetime.strptime(cursor_end, "%Y%m%d") - timedelta(days=180) + ).strftime("%Y%m%d") + # 단, 실제 요청 시작일(sd)보다 앞으로 가지 않음 + page_date1 = max(window_start, sd) + + data = None + for retry in range(max_retries): + try: + headers = { + "Content-Type": "application/json", + "authorization": f"Bearer {token}", + "appkey": app_key, + "appsecret": app_secret, + "tr_id": "FHKST03010100", + } + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_DATE_1": page_date1, # 윈도우 시작 + "FID_INPUT_DATE_2": cursor_end, # 윈도우 끝 (커서) + "FID_PERIOD_DIV_CODE": "D", + "FID_ORG_ADJ_PRC": "0", + } + resp = requests.get(url, headers=headers, params=params, timeout=10) + if resp.status_code == 429: + time.sleep((retry + 1) * 2) + continue + resp.raise_for_status() + data = resp.json() + break + except Exception as e: + if retry >= max_retries - 1: + logger.error(f"❌ 일봉 조회 실패 {code} (page {attempt_page}): {e}") + return all_rows + time.sleep(2) + + if data is None: + break + + rows = data.get("output2", []) + if not rows: + break + + done = False + for item in rows: + dt = item.get("stck_bsop_date", "") # YYYYMMDD + if dt < sd: # start_date 이전 → 수집 완료 + done = True + break + c = float(item.get("stck_clpr", 0) or 0) + if c <= 0: + continue + all_rows.append({ + "date": dt, + "open": float(item.get("stck_oprc", 0) or 0), + "high": float(item.get("stck_hgpr", 0) or 0), + "low": float(item.get("stck_lwpr", 0) or 0), + "close": c, + "volume": int(item.get("acml_vol", 0) or 0), + "change_rate": float(item.get("prdy_ctrt", 0) or 0), # 전일대비등락률(%) + }) + + if done: + break + + # 다음 페이지: 이번 배치 최고(가장 오래된) 날짜 - 1일 + oldest_dt = rows[-1].get("stck_bsop_date", "") + if not oldest_dt or oldest_dt <= sd: + break + prev = (datetime.strptime(oldest_dt, "%Y%m%d") - timedelta(days=1)).strftime("%Y%m%d") + if prev == cursor_end: # 무한루프 방지 + break + cursor_end = prev + time.sleep(0.3) # API 과부하 방지 + + return all_rows + +def store_candles(db: TradeDB, code: str, rows: List[Dict]) -> int: + """일봉 데이터를 holding_candles에 저장 (ON DUPLICATE KEY UPDATE)""" + saved = 0 + for r in rows: + try: + d = r["date"] + date_fmt = f"{d[:4]}-{d[4:6]}-{d[6:8]}" + db.conn.execute( + """INSERT INTO holding_candles + (code, candle_date, open, high, low, close, volume, change_rate) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + open=VALUES(open), high=VALUES(high), low=VALUES(low), + close=VALUES(close), volume=VALUES(volume), + change_rate=VALUES(change_rate)""", + [code, date_fmt, + r["open"], r["high"], r["low"], r["close"], + r["volume"], r.get("change_rate", 0)] + ) + saved += 1 + except Exception as e: + logger.debug(f"캔들 저장 오류 ({code} {r.get('date')}): {e}") + db.conn.commit() + return saved + +def get_stored_candles(db: TradeDB, code: str, + start_date: str = "", end_date: str = "") -> List[Dict]: + """holding_candles에서 종목 일봉 조회 (날짜 오름차순, change_rate 포함)""" + sql = """SELECT candle_date, open, high, low, close, volume, + IFNULL(change_rate, 0) AS change_rate + FROM holding_candles WHERE code=%s""" + args = [code] + if start_date: + sql += " AND candle_date >= %s" + args.append(start_date) + if end_date: + sql += " AND candle_date <= %s" + args.append(end_date) + sql += " ORDER BY candle_date ASC" + rows = db.conn.execute(sql, args).fetchall() + return [dict(r) for r in rows] + +# ───────────────────────────────────────────────────────────────────────────── +# 60분봉 수집 / 저장 / 조회 +# ───────────────────────────────────────────────────────────────────────────── + +class _KiwoomTokenManager: + """ + kiwoom_universe_scanner.py / kiwoom_trader_dual.py 의 TokenManager와 + 동일한 인터페이스 — DB에서 읽은 키를 직접 주입 (env 변수 오염 없음). + + TokenManager.get_token() 호환: Chart(token_manager=...) 에 그대로 전달 가능. + """ + def __init__(self, app_key: str, app_secret: str, is_mock: bool = False): + self._app_key = app_key + self._app_secret = app_secret + self._domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com" + self._token: Optional[str] = None + self._expiry: Optional[datetime] = None + + def _is_valid(self) -> bool: + if not self._token or not self._expiry: + return False + return datetime.now() < self._expiry - timedelta(seconds=30) + + def _request_new_token(self) -> None: + """키움 /oauth2/token 으로 새 토큰 발급 (kiwoom_universe_scanner 와 동일 흐름)""" + url = f"https://{self._domain}/oauth2/token" + body = { + "grant_type": "client_credentials", + "appkey": self._app_key, + "secretkey": self._app_secret, + } + resp = requests.post(url, json=body, timeout=15) + data = resp.json() + token = data.get("token") or data.get("access_token", "") + if not token: + raise RuntimeError( + f"키움 {'모의' if '모의' in self._domain else '실전'} 토큰 발급 실패: {data}" + ) + # 만료 시간 파싱 (expires_dt: "YYYYMMDDHHMMSS") + exp_s = data.get("expires_dt", "") + try: + self._expiry = datetime.strptime(str(exp_s), "%Y%m%d%H%M%S") + except Exception: + self._expiry = datetime.now() + timedelta(hours=23) + self._token = token + logger.info( + f"✅ 키움 {'모의' if '모의' in self._domain else '실전'} 토큰 발급 완료" + f" (앞8자: {token[:8]}…, 만료: {self._expiry})" + ) + + def get_token(self) -> str: + """ + TokenManager.get_token() 호환 — 만료 시 자동 재발급. + kis_ws.KiwoomTokenManager 싱글톤 풀을 공유하여 au10001 rate limit 방지. + (동일 appkey로 kis_ws 에서 이미 발급받은 토큰은 재사용) + """ + try: + from kis_ws import _get_kiwoom_token_cached + is_mock = "mockapi" in self._domain + token = _get_kiwoom_token_cached(self._app_key, self._app_secret, is_mock) + if token: + self._token = token + return self._token + except Exception: + pass + # kis_ws 임포트 실패 시 자체 로직으로 폴백 + if not self._is_valid(): + self._request_new_token() + return self._token + + +def fetch_60min_via_kiwoom( + code: str, + start_date: str, # "YYYY-MM-DD" + end_date: str, # "YYYY-MM-DD" + kiwoom_key: str, # DB env_config의 KIWOOM_APP_KEY_REAL 또는 KIWOOM_APP_KEY_MOCK + kiwoom_secret: str, # DB env_config의 KIWOOM_APP_SECRET_REAL 또는 KIWOOM_APP_SECRET_MOCK + is_mock: bool = False, # True → mockapi.kiwoom.com (모의) + max_pages: int = 50, # 연속조회 최대 횟수 (1페이지 ≈ 900봉) +) -> List[Dict]: + """ + 키움증권 REST API (ka10080) 로 60분봉 수집 → holding_min_candles 저장용. + + kiwoom_universe_scanner.py / kiwoom_trader_dual.py 와 동일한 + TokenManager + Chart 패턴 사용. + + ▶ KIS 한계: inquire-time-itemchartprice 는 당일(2일치) 데이터만 제공 + ▶ 키움 장점: ka10080 1회 호출 = 900봉 (60분봉 기준 약 128영업일) + cont-yn: Y + next-key 연속조회로 2년치 이상 수집 가능 + + 반환: [{"candle_date": "YYYY-MM-DD HH:MM:SS", "open": float, "high": float, + "low": float, "close": float, "volume": int}, ...] + 최신 → 과거 순, start_date~end_date 범위 필터 적용 + """ + from kiwoom_rest_api.koreanstock.chart import Chart + + mode_str = "모의" if is_mock else "실전" + base_url = "https://mockapi.kiwoom.com" if is_mock else "https://api.kiwoom.com" + logger.info(f"키움 60분봉 수집 시작: {code} [{mode_str}] {start_date}~{end_date}") + + # ── 1. TokenManager + Chart 초기화 (kiwoom_universe_scanner 와 동일 패턴) ── + try: + token_manager = _KiwoomTokenManager(kiwoom_key, kiwoom_secret, is_mock=is_mock) + chart = Chart(base_url=base_url, token_manager=token_manager) + # 최초 토큰 발급 확인 + _ = token_manager.get_token() + except Exception as e: + logger.error(f"❌ 키움 초기화 실패 [{mode_str}]: {e}") + return [] + + # ── 2. 날짜 범위 파싱 ───────────────────────────────────────────────────── + start_dt = datetime.strptime(start_date, "%Y-%m-%d") + end_dt = datetime.strptime(end_date, "%Y-%m-%d") + + rows: List[Dict] = [] + cont_yn = "N" + next_key = "" + + for page in range(max_pages): + try: + resp = chart.stock_minute_chart_request_ka10080( + stk_cd = code, + tic_scope = "60", # 60분봉 (1/3/5/10/15/30/45/60 지원) + upd_stkpc_tp = "1", # 수정주가 반영 + cont_yn = cont_yn, + next_key = next_key, + ) + except Exception as e: + logger.error(f"❌ 키움 ka10080 조회 실패 (page {page}): {e}") + break + + records = resp.get("stk_min_pole_chart_qry") or [] + if not records: + logger.info(f"키움 60분봉: 더 이상 데이터 없음 (page {page})") + break + + reached_start = False + for rec in records: + # cntr_tm: "YYYYMMDDHHMMSS" 형식 + raw_dt = str(rec.get("cntr_tm", "")) + if len(raw_dt) < 14: + continue + try: + candle_dt = datetime( + int(raw_dt[0:4]), int(raw_dt[4:6]), int(raw_dt[6:8]), + int(raw_dt[8:10]), int(raw_dt[10:12]), int(raw_dt[12:14]), + ) + except Exception: + continue + + # 역방향(최신→과거) 수집이므로 start_date보다 과거이면 종료 + if candle_dt.date() < start_dt.date(): + reached_start = True + break + if candle_dt.date() > end_dt.date(): + continue + + try: + rows.append({ + "candle_date": candle_dt.strftime("%Y-%m-%d %H:%M:%S"), + "open": abs(float(rec.get("open_pric", 0) or 0)), + "high": abs(float(rec.get("high_pric", 0) or 0)), + "low": abs(float(rec.get("low_pric", 0) or 0)), + "close": abs(float(rec.get("cur_prc", 0) or 0)), + "volume": abs(int(float(rec.get("trde_qty", 0) or 0))), + }) + except Exception: + continue + + if reached_start: + break + + # 연속조회 키는 응답 dict에서 읽음 (Chart._execute_request 가 헤더를 dict로 포함) + new_cont = str(resp.get("cont-yn", resp.get("cont_yn", "N"))).upper() + new_nkey = str(resp.get("next-key", resp.get("next_key", ""))) + if new_cont != "Y" or not new_nkey: + break + cont_yn = new_cont + next_key = new_nkey + time.sleep(0.3) # API 레이트리밋 준수 (초당 3회 이하) + + logger.info(f"✅ 키움 60분봉 수집 완료: {code} {len(rows)}봉 ({start_date}~{end_date})") + return rows + +def fetch_min_ohlcv( + code: str, + start_date: str, # "YYYY-MM-DD" + end_date: str, # "YYYY-MM-DD" + app_key: str, + app_secret: str, + base_url: str, + tf_min: int = 60, # 집계 단위 (60 = 60분봉) + mock: bool = False, + max_retries: int = 3, +) -> List[Dict]: + """ + KIS FHKST03010200(분봉조회) 1분봉 수집 → tf_min 단위 집계 반환. + + 페이지네이션 전략: + FID_INPUT_HOUR_1=HHMMSS 커서로 역방향(최신→과거) 순회. + FID_PW_DATA_INCU_YN=Y → 과거 날짜까지 연속 조회 가능. + 각 응답 ~30 레코드, 마지막 레코드 절대 시각 - 1분을 다음 커서로 사용. + + 60분봉 집계 규칙 (역방향 수집이므로 open/close 처리 주의): + 처음 만난 레코드 = 이 버킷의 가장 최신(close) + 이후 이전 레코드로 갈수록 open 덮어씀 → 결국 가장 오래된 open이 남음 + high = MAX, low = MIN, volume = SUM + """ + token = _ensure_token(mock) + url = f"{base_url}/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + + try: + sd = datetime.strptime(start_date, "%Y-%m-%d") + ed = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError as e: + raise ValueError(f"날짜 형식 오류: {e}") + + # 60분봉 버킷 집계 저장소 + buckets: Dict[datetime, Dict] = {} + cursor_dt = ed.replace(hour=15, minute=30, second=0) # end_date 15:30 부터 역순 + + total_1min = 0 # 수집한 1분봉 수 (로그용) + + for page in range(3000): # 최대 ~90,000 1분봉 (약 7개월치) + cursor_str = cursor_dt.strftime("%H%M%S") + + data = None + for retry in range(max_retries): + try: + headers = { + "Content-Type": "application/json", + "authorization": f"Bearer {token}", + "appkey": app_key, + "appsecret": app_secret, + # 분봉 조회는 실전/모의 도메인만 다르고 TR_ID는 동일 + "tr_id": "FHKST03010200", + } + params = { + "FID_ETC_CLS_CODE": "", + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_HOUR_1": cursor_str, + "FID_PW_DATA_INCU_YN": "Y", # 과거 날짜 데이터 포함 + } + resp = requests.get(url, headers=headers, params=params, timeout=10) + if resp.status_code == 429: + time.sleep((retry + 1) * 2) + continue + resp.raise_for_status() + data = resp.json() + break + except Exception as e: + if retry >= max_retries - 1: + logger.error(f"❌ 분봉 조회 실패 {code} (page {page}): {e}") + return _buckets_to_min_list(buckets, tf_min) + time.sleep(2) + + if data is None: + break + + rows = data.get("output2", []) + if not rows: + break + + done = False + last_abs_dt = None + + for row in rows: + date_s = row.get("stck_bsop_date", "") + time_s = row.get("stck_cntg_hour", "") + if len(date_s) < 8 or len(time_s) < 6: + continue + try: + abs_dt = datetime.strptime(date_s + time_s, "%Y%m%d%H%M%S") + except ValueError: + continue + + last_abs_dt = abs_dt + + if abs_dt.date() > ed.date(): + continue # end_date 이후 → 스킵 + + if abs_dt.date() < sd.date(): + done = True # start_date 이전 → 수집 완료 + break + + # 장중 시간만 처리 (09:00~15:30) + market_min = abs_dt.hour * 60 + abs_dt.minute + if market_min < 540 or market_min > 930: # 09:00 ~ 15:30 + continue + + c = float(row.get("stck_prpr", 0) or 0) + if c <= 0: + continue + + total_1min += 1 + + # tf_min 단위 버킷 시작 시간 계산 (09:00, 10:00, 11:00, ...) + bucket_min = (abs_dt.minute // tf_min) * tf_min + bucket_dt = abs_dt.replace(minute=bucket_min, second=0, microsecond=0) + + o = float(row.get("stck_oprc", c) or c) + h = float(row.get("stck_hgpr", c) or c) + l = float(row.get("stck_lwpr", c) or c) + v = int(row.get("cntg_vol", 0) or 0) + + if bucket_dt not in buckets: + # 역방향: 처음 만나는 레코드 = 버킷의 가장 최신 = close + buckets[bucket_dt] = {"open": o, "high": h, "low": l, "close": c, "volume": v} + else: + b = buckets[bucket_dt] + b["open"] = o # 더 이른 open으로 계속 갱신 + b["high"] = max(b["high"], h) + b["low"] = min(b["low"], l) + b["volume"] += v + # close는 첫 기록(최신) 유지 — 갱신하지 않음 + + if done: + break + + # 다음 커서: 이번 배치 마지막(가장 오래된) 절대 시각 - 1분 + if last_abs_dt is None: + break + next_cur = last_abs_dt - timedelta(minutes=1) + if next_cur.date() < sd.date(): + break + cursor_dt = next_cur + time.sleep(0.15) # API 과부하 방지 (~6 req/s) + + logger.info(f" [{code}] 1분봉 {total_1min}개 수집 → {tf_min}분봉 {len(buckets)}개 집계") + return _buckets_to_min_list(buckets, tf_min) + + +def fetch_and_store_min_candles( + db: "TradeDB", + code: str, + start_date: str, + end_date: str, + app_key: str, + app_secret: str, + base_url: str, + tf_min: int = 60, + mock: bool = False, + max_retries: int = 3, + progress: Optional[Dict] = None, # 외부 dict: {"status","fetched","saved"} +) -> int: + """ + 1분봉 페이지 단위로 수집하면서 날짜가 바뀌는 순간 해당 날의 버킷을 즉시 DB 저장. + Flask 백그라운드 스레드에서 호출해 웹페이지가 블로킹되지 않도록 한다. + progress dict를 통해 진행상황을 실시간으로 노출한다. + """ + def _upd(**kw): + if progress is not None: + progress.update(kw) + + token = _ensure_token(mock) + url = f"{base_url}/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + + try: + sd = datetime.strptime(start_date, "%Y-%m-%d") + ed = datetime.strptime(end_date, "%Y-%m-%d") + except ValueError as e: + raise ValueError(f"날짜 형식 오류: {e}") + + # 날짜별로 버킷 집계: {date: {bucket_dt: {o,h,l,c,v}}} + day_buckets: Dict[str, Dict[datetime, Dict]] = {} + cursor_dt = ed.replace(hour=15, minute=30, second=0) + total_1min = 0 + total_saved = 0 + prev_date_s: Optional[str] = None # 이전 루프의 날짜 (날짜 전환 감지용) + + for page in range(3000): + cursor_str = cursor_dt.strftime("%H%M%S") + + data = None + for retry in range(max_retries): + try: + headers = { + "Content-Type": "application/json", + "authorization": f"Bearer {token}", + "appkey": app_key, + "appsecret": app_secret, + "tr_id": "FHKST03010200", + } + params = { + "FID_ETC_CLS_CODE": "", + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_HOUR_1": cursor_str, + "FID_PW_DATA_INCU_YN": "Y", + } + resp = requests.get(url, headers=headers, params=params, timeout=10) + if resp.status_code == 429: + time.sleep((retry + 1) * 2) + continue + resp.raise_for_status() + data = resp.json() + break + except Exception as e: + if retry >= max_retries - 1: + logger.error(f"❌ 분봉 조회 실패 {code} (page {page}): {e}") + data = None + break + time.sleep(2) + + if data is None: + break + + rows = data.get("output2", []) + if not rows: + break + + done = False + last_abs_dt = None + + for row in rows: + date_s = row.get("stck_bsop_date", "") + time_s = row.get("stck_cntg_hour", "") + if len(date_s) < 8 or len(time_s) < 6: + continue + try: + abs_dt = datetime.strptime(date_s + time_s, "%Y%m%d%H%M%S") + except ValueError: + continue + + last_abs_dt = abs_dt + + if abs_dt.date() > ed.date(): + continue + if abs_dt.date() < sd.date(): + done = True + break + + market_min = abs_dt.hour * 60 + abs_dt.minute + if market_min < 540 or market_min > 930: + continue + + c = float(row.get("stck_prpr", 0) or 0) + if c <= 0: + continue + + total_1min += 1 + cur_date_s = date_s[:8] # "YYYYMMDD" + + # 날짜가 바뀌면 이전 날짜 데이터를 즉시 DB에 flush + if prev_date_s is not None and cur_date_s != prev_date_s: + flush_buckets = day_buckets.pop(prev_date_s, {}) + if flush_buckets: + rows_to_save = _buckets_to_min_list(flush_buckets, tf_min) + n = store_min_candles(db, code, rows_to_save, tf_min) + total_saved += n + _upd(saved=total_saved, fetched=total_1min, + current_date=prev_date_s) + logger.info(f" [{code}] {prev_date_s} → {tf_min}분봉 {n}개 저장 (누적 {total_saved})") + + prev_date_s = cur_date_s + + bucket_min = (abs_dt.minute // tf_min) * tf_min + bucket_dt = abs_dt.replace(minute=bucket_min, second=0, microsecond=0) + + o = float(row.get("stck_oprc", c) or c) + h = float(row.get("stck_hgpr", c) or c) + l = float(row.get("stck_lwpr", c) or c) + v = int(row.get("cntg_vol", 0) or 0) + + if cur_date_s not in day_buckets: + day_buckets[cur_date_s] = {} + bkt = day_buckets[cur_date_s] + + if bucket_dt not in bkt: + bkt[bucket_dt] = {"open": o, "high": h, "low": l, "close": c, "volume": v} + else: + b = bkt[bucket_dt] + b["open"] = o + b["high"] = max(b["high"], h) + b["low"] = min(b["low"], l) + b["volume"] += v + + _upd(fetched=total_1min) + + if done: + break + + if last_abs_dt is None: + break + next_cur = last_abs_dt - timedelta(minutes=1) + if next_cur.date() < sd.date(): + break + cursor_dt = next_cur + time.sleep(0.15) + + # 루프 종료 후 남은 날짜 데이터 flush + for date_s, flush_buckets in day_buckets.items(): + if flush_buckets: + rows_to_save = _buckets_to_min_list(flush_buckets, tf_min) + n = store_min_candles(db, code, rows_to_save, tf_min) + total_saved += n + _upd(saved=total_saved, fetched=total_1min) + logger.info(f" [{code}] {date_s}(말미) → {tf_min}분봉 {n}개 저장 (누적 {total_saved})") + + logger.info(f" [{code}] 수집완료: 1분봉 {total_1min}개 → {tf_min}분봉 {total_saved}개 저장") + return total_saved + + +def _buckets_to_min_list(buckets: Dict, tf_min: int) -> List[Dict]: + """버킷 dict → 시간순 list 변환 (store_min_candles 입력용)""" + return [ + { + "dt": dt.strftime("%Y-%m-%d %H:%M:%S"), + "tf": tf_min, + "open": b["open"], + "high": b["high"], + "low": b["low"], + "close": b["close"], + "volume": b["volume"], + } + for dt, b in sorted(buckets.items()) + ] + + +def store_min_candles(db: TradeDB, code: str, + rows: List[Dict], tf_min: int = 60) -> int: + """60분봉 데이터를 holding_min_candles에 저장 (ON DUPLICATE KEY UPDATE)""" + saved = 0 + for r in rows: + try: + db.conn.execute( + """INSERT INTO holding_min_candles + (code, candle_dt, tf, open, high, low, close, volume) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + open=VALUES(open), high=VALUES(high), low=VALUES(low), + close=VALUES(close), volume=VALUES(volume)""", + [code, r["dt"], tf_min, + r["open"], r["high"], r["low"], r["close"], r["volume"]] + ) + saved += 1 + except Exception as e: + logger.debug(f"분봉 저장 오류 ({code} {r.get('dt')}): {e}") + db.conn.commit() + return saved + + +def get_stored_min_candles(db: TradeDB, code: str, + start_dt: str = "", end_dt: str = "", + tf_min: int = 60) -> List[Dict]: + """ + holding_min_candles에서 종목 분봉 조회 (시간 오름차순). + run_backtest() 호환: candle_dt를 candle_date 키로 반환 + """ + sql = """SELECT candle_dt AS candle_date, + open, high, low, close, volume + FROM holding_min_candles WHERE code=%s AND tf=%s""" + args = [code, tf_min] + if start_dt: + sql += " AND candle_dt >= %s" + args.append(start_dt) + if end_dt: + end_val = end_dt + " 23:59:59" if len(end_dt) == 10 else end_dt + sql += " AND candle_dt <= %s" + args.append(end_val) + sql += " ORDER BY candle_dt ASC" + rows = db.conn.execute(sql, args).fetchall() + return [dict(r) for r in rows] + + +def get_min_candle_stats(db: TradeDB, code: str, tf_min: int = 60) -> Dict: + """60분봉 보유 현황 (개수, 최소/최대 datetime)""" + row = db.conn.execute( + "SELECT COUNT(*) AS cnt, MIN(candle_dt) AS mn, MAX(candle_dt) AS mx " + "FROM holding_min_candles WHERE code=%s AND tf=%s", + [code, tf_min] + ).fetchone() + if row and row["cnt"]: + return { + "count": int(row["cnt"]), + "min": str(row["mn"])[:16] if row["mn"] else "", + "max": str(row["mx"])[:16] if row["mx"] else "", + } + return {"count": 0, "min": "", "max": ""} + +# ───────────────────────────────────────────────────────────────────────────── +# 기술 지표 계산 +# ───────────────────────────────────────────────────────────────────────────── +def _rsi_series(closes: List[float], period: int) -> List[Optional[float]]: + """Wilder RSI 시리즈""" + n = len(closes) + rsi = [None] * n + if n < period + 1: + return rsi + deltas = [closes[i] - closes[i - 1] for i in range(1, n)] + gains = [max(d, 0) for d in deltas] + losses = [max(-d, 0) for d in deltas] + ag = sum(gains[:period]) / period + al = sum(losses[:period]) / period + for i in range(period, n): + idx = i - 1 + if i > period: + ag = (ag * (period - 1) + gains[idx]) / period + al = (al * (period - 1) + losses[idx]) / period + rs = ag / al if al > 0 else float("inf") + rsi[i] = 100.0 - (100.0 / (1 + rs)) if al > 0 else 100.0 + return rsi + +def _ma_series(closes: List[float], period: int) -> List[Optional[float]]: + """단순이동평균(SMA) 시리즈. period 미만 구간은 None.""" + n = len(closes) + ma = [None] * n + if period < 1 or n < period: + return ma + window_sum = sum(closes[:period]) + ma[period - 1] = window_sum / period + for i in range(period, n): + window_sum += closes[i] - closes[i - period] + ma[i] = window_sum / period + return ma + + +def _atr_series(highs, lows, closes, period: int) -> List[Optional[float]]: + """ATR 시리즈""" + n = len(closes) + tr_list = [None] * n + for i in range(1, n): + tr_list[i] = max( + highs[i] - lows[i], + abs(highs[i] - closes[i - 1]), + abs(lows[i] - closes[i - 1]), + ) + atr = [None] * n + if n < period + 1: + return atr + atr[period] = sum(t for t in tr_list[1:period+1] if t) / period + for i in range(period + 1, n): + if tr_list[i] is not None and atr[i - 1] is not None: + atr[i] = (atr[i - 1] * (period - 1) + tr_list[i]) / period + return atr + +# ───────────────────────────────────────────────────────────────────────────── +# 컨텍스트 신호 계산 (ATH · 연도고점 · 52주 고/저) +# ───────────────────────────────────────────────────────────────────────────── +def _calc_context_signals(candles: List[Dict], current_price: float) -> Dict: + """ + DB 일봉 전체를 기반으로 현재가 대비 컨텍스트 낙폭 지표를 반환. + 백테스트와 실매매 양쪽에서 재사용. + + 반환 키: + ath - 역대 최고가 (DB 전체 기간 high 최댓값) + year_high - 당해 연도 최고가 + w52_high - 최근 252봉(약 1년) 최고가 + w52_low - 최근 252봉 최저가 + ath_drop_pct - 현재가 기준 ATH 대비 낙폭 (%) ← 클수록 많이 빠진 것 + year_drop_pct - 연도고점 대비 낙폭 (%) + w52_drop_pct - 52주 고점 대비 낙폭 (%) + """ + if not candles: + return { + "ath": current_price, "year_high": current_price, + "w52_high": current_price, "w52_low": current_price, + "ath_drop_pct": 0.0, "year_drop_pct": 0.0, "w52_drop_pct": 0.0, + } + + this_year = str(datetime.now().year) + all_highs = [float(c["high"]) for c in candles if float(c["high"]) > 0] + year_highs = [float(c["high"]) for c in candles + if str(c["candle_date"])[:4] == this_year and float(c["high"]) > 0] + # 52주 ≈ 최근 252 거래일 + w52_bars = candles[-252:] if len(candles) >= 252 else candles + w52_highs = [float(c["high"]) for c in w52_bars if float(c["high"]) > 0] + w52_lows = [float(c["low"]) for c in w52_bars if float(c["low"]) > 0] + + ath = max(all_highs) if all_highs else current_price + year_high = max(year_highs) if year_highs else current_price + w52_high = max(w52_highs) if w52_highs else current_price + w52_low = min(w52_lows) if w52_lows else current_price + + def drop_pct(ref: float) -> float: + return round((ref - current_price) / ref * 100, 2) if ref > 0 else 0.0 + + return { + "ath": ath, + "year_high": year_high, + "w52_high": w52_high, + "w52_low": w52_low, + "ath_drop_pct": drop_pct(ath), + "year_drop_pct": drop_pct(year_high), + "w52_drop_pct": drop_pct(w52_high), + } + +# ───────────────────────────────────────────────────────────────────────────── +# 백테스트 엔진 +# ───────────────────────────────────────────────────────────────────────────── +def run_backtest( + candles: List[Dict], + cfg: Dict, + fee_rate: float = 0.015 / 100, # 매수/매도 각각 0.015% + sell_tax: float = 0.18 / 100, # 증권거래세 0.18% + tf_min: int = 0, # 0=일봉, 60=60분봉 등. 0이면 자동감지 +) -> Dict: + """ + 홀딩 전략 백테스트 (추세추종 + 눌림목 / 역추세 선택) + + [추세 상승: MA_FAST > MA_SLOW] + 매수: RSI ≤ rsi_buy1 → 1차, RSI ≤ rsi_buy2 → 2차 추가매수 + 매도: 트레일링 스탑(고점 대비 trail_stop_pct% 하락) + OR RSI 과열(rsi_sell) OR 익절(take_profit_pct) + OR 데드크로스(MA_FAST < MA_SLOW) 발생 시 추세 반전 청산 + + [추세 하락/횡보: MA_FAST ≤ MA_SLOW] or [trend_filter=0] + 매수: RSI ≤ rsi_buy1/2/3 (기존 3단계 역추세 분할매수) + 매도: 익절(take_profit_pct) OR RSI 과열 OR 손절(stop_loss_pct) + + 진입가: 신호봉 다음 봉 시가 (1봉 지연) + """ + if len(candles) < 20: + return {"error": "봉 부족", "total_trades": 0} + + # ── 봉 유형 자동 감지 ───────────────────────────────────────────── + _sample_dt = str(candles[0].get("candle_date", "")) + _is_min = tf_min > 0 or len(_sample_dt) > 10 + + rsi_period = int(cfg.get("rsi_period", 14)) + rsi_buy1 = float(cfg.get("rsi_buy1", 55)) + rsi_buy2 = float(cfg.get("rsi_buy2", 45)) + rsi_buy3 = float(cfg.get("rsi_buy3", 35)) + rsi_sell = float(cfg.get("rsi_sell", 75)) + tp_pct = float(cfg.get("take_profit_pct", 15.0)) / 100 + sl_pct = float(cfg.get("stop_loss_pct", 10.0)) / 100 + buy1_ratio = float(cfg.get("buy1_ratio", 50)) / 100 + buy2_ratio = float(cfg.get("buy2_ratio", 50)) / 100 + buy3_ratio = float(cfg.get("buy3_ratio", 0)) / 100 + slot_money = float(cfg.get("slot_money", 3_000_000)) + ma_fast_p = int(cfg.get("ma_fast", 20)) + ma_slow_p = int(cfg.get("ma_slow", 60)) + trail_stop_p = float(cfg.get("trail_stop_pct", 8.0)) # %, 0=비활성 + trend_filter = float(cfg.get("trend_filter", 1.0)) >= 0.5 # bool 변환 + atr_period_p = int(cfg.get("atr_period", 14)) + # 샹들리에 엑시트 배수: env/카드에서 조정 가능 (하드코딩 금지) + atr_mult = float(cfg.get("atr_mult", 3.0)) + ath_drop_min = float(cfg.get("ath_drop_min_pct", 0.0)) + year_drop_min = float(cfg.get("year_drop_min_pct", 0.0)) + w52_drop_min = float(cfg.get("w52_drop_min_pct", 0.0)) + + 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] + datetimes = [str(c["candle_date"]) for c in candles] + dates = [s[:10] for s in datetimes] + + rsis = _rsi_series(closes, rsi_period) + ma_fast_arr = _ma_series(closes, ma_fast_p) + ma_slow_arr = _ma_series(closes, ma_slow_p) + # ATR: 샹들리에 엑시트에 사용 (추세장 청산 핵심 지표) + atr_arr = _atr_series(highs, lows, closes, atr_period_p) + + # ── 컨텍스트 배열 사전 계산 ────────────────────────────────────── + ath_arr = [] + year_high_arr = [] + w52_high_arr = [] + w52_low_arr = [] + _year_max: Dict[str, float] = {} + _ath_run = 0.0 + + from datetime import date as _date, timedelta as _td + for i in range(len(candles)): + h = highs[i]; l = lows[i]; y = dates[i][:4] + _ath_run = max(_ath_run, h) + _year_max[y] = max(_year_max.get(y, 0.0), h) + if _is_min: + try: + cutoff = str(_date.fromisoformat(dates[i]) - _td(days=365)) + w52_s = i + while w52_s > 0 and dates[w52_s - 1] >= cutoff: + w52_s -= 1 + except Exception: + w52_s = max(0, i - 252 * 7) + else: + w52_s = max(0, i - 251) + ath_arr.append(_ath_run) + year_high_arr.append(_year_max[y]) + w52_high_arr.append(max(highs[w52_s:i + 1])) + w52_low_arr.append(min(lows[w52_s:i + 1])) + + # ── 포지션 상태 ─────────────────────────────────────────────────── + position = None # None or dict + trades: List[Dict] = [] + equity: List[Dict] = [] + cum_pnl = 0.0 + + start_i = max(rsi_period + 1, ma_slow_p) # MA_SLOW가 준비된 이후부터 진입 + + for i in range(start_i, len(candles) - 1): + rsi = rsis[i] + if rsi is None: + continue + + next_open = float(opens[i + 1]) if opens[i + 1] > 0 else closes[i] + if next_open <= 0: + continue + + mf = ma_fast_arr[i] # 단기 MA 현재봉 + ms = ma_slow_arr[i] # 장기 MA 현재봉 + mf_prev = ma_fast_arr[i - 1] if i > 0 else mf + ms_prev = ma_slow_arr[i - 1] if i > 0 else ms + + # 추세 방향 판단 + is_uptrend = trend_filter and mf is not None and ms is not None and mf > ms + + # ─── 데드크로스 감지: 추세 반전 신호 ───────────────────────── + is_deadcross = ( + trend_filter + and mf is not None and ms is not None + and mf_prev is not None and ms_prev is not None + and mf < ms # 현재봉: MA_FAST < MA_SLOW + and mf_prev >= ms_prev # 직전봉: MA_FAST ≥ MA_SLOW (방금 크로스) + ) + + # ─── 보유 중: 매도 체크 ─────────────────────────────────────── + if position is not None: + avg = position["avg"] + qty = position["qty"] + c_now = closes[i] + + # 트레일링 스탑: 보유 중 최고가 업데이트 후 고점 대비 낙폭 체크 + position["peak"] = max(position["peak"], c_now) + profit_pct = (c_now - avg) / avg if avg > 0 else 0.0 + sell_reason = None + + if position.get("trend_mode"): + # ─── 추세장 청산: 고정 익절·RSI과열 무시 → 끝까지 추세 추적 ─── + # 핵심: rsi_sell·tp_pct로 조기 청산하면 200% 추세를 15%에 팔게 됨 + if is_deadcross: + # MA 데드크로스 → 추세 반전 확정 → 즉시 청산 + sell_reason = f"데드크로스({mf:.0f}<{ms:.0f})" + else: + # 샹들리에 엑시트 = 고점 − ATR × 배수 (변동성 기반 동적 방어선) + # ATR이 클수록 방어선이 낮아져 변동성 큰 종목에 더 여유를 줌 + _atr_v = atr_arr[i] if (atr_arr[i] is not None) else closes[i] * 0.05 + chandelier = position["peak"] - _atr_v * atr_mult + if trail_stop_p > 0: + # 퍼센트 방어선도 계산, 더 타이트한(높은) 쪽 채택 + pct_trail = position["peak"] * (1.0 - trail_stop_p / 100.0) + chandelier = max(chandelier, pct_trail) + if c_now <= chandelier: + sell_reason = f"샹들리에({profit_pct*100:+.1f}%)" + elif profit_pct <= -sl_pct: + # 손절은 추세장에서도 반드시 유지 (무제한 손실 방지) + sell_reason = f"손절({profit_pct*100:.1f}%)" + else: + # ─── 횡보/역추세장 청산: 철저히 고정 익절·손절비 준수 ────────── + if profit_pct >= tp_pct: + sell_reason = f"익절(+{profit_pct*100:.1f}%)" + elif rsi >= rsi_sell: + sell_reason = f"RSI과열({rsi:.1f})" + elif profit_pct <= -sl_pct: + sell_reason = f"손절({profit_pct*100:.1f}%)" + + if sell_reason: + exit_price = next_open + fee = exit_price * qty * (fee_rate + sell_tax) + pnl = (exit_price - avg) * qty - fee + hold = i - position["entry_i"] + cum_pnl += pnl + trades.append({ + "buy_date": datetimes[position["entry_i"]][:16], + "sell_date": datetimes[i + 1][:16], + "avg_price": round(avg), + "exit_price": round(exit_price), + "qty": qty, + "pnl": round(pnl), + "hold_days": hold, + "reason": sell_reason, + "rsi_sell": round(rsi, 1), + "ath_drop": position.get("ath_drop", 0), + "year_drop": position.get("year_drop", 0), + "w52_drop": position.get("w52_drop", 0), + }) + equity.append({"date": datetimes[i + 1][:16], "cum_pnl": round(cum_pnl)}) + position = None + continue # 매도 후 당일 매수 방지 + + # ─── 미보유: 컨텍스트 낙폭 필터 ───────────────────────────── + price_now = closes[i] + def _drop(ref: float) -> float: + return (ref - price_now) / ref * 100 if ref > 0 else 0.0 + + if ath_drop_min > 0 and _drop(ath_arr[i]) < ath_drop_min: continue + if year_drop_min > 0 and _drop(year_high_arr[i]) < year_drop_min: continue + if w52_drop_min > 0 and _drop(w52_high_arr[i]) < w52_drop_min: continue + + # ─── 추세 상승 모드 매수 ───────────────────────────────────── + if is_uptrend: + # RSI 눌림목: rsi_buy1(1차), rsi_buy2(2차) 기준으로 진입 + stage = 0 + if rsi <= rsi_buy2: stage = 2 + elif rsi <= rsi_buy1: stage = 1 + if stage == 0: + continue + ratio = buy2_ratio if stage == 2 else buy1_ratio + invest = slot_money * ratio + qty = max(1, int(invest / next_open)) + cost = next_open * qty * (1 + fee_rate) + position = { + "avg": next_open, + "qty": qty, + "cost": cost, + "stage": stage, + "entry_i": i + 1, + "peak": next_open, # 트레일링 스탑용 고점 + "trend_mode": True, # 추세 모드 진입 여부 (청산 로직 구분) + "ath_drop": round(_drop(ath_arr[i]), 1), + "year_drop": round(_drop(year_high_arr[i]), 1), + "w52_drop": round(_drop(w52_high_arr[i]), 1), + } + + # ─── 횡보/하락 모드 (역추세 분할매수) ─────────────────────── + else: + stage = 0 + if rsi <= rsi_buy3: stage = 3 + elif rsi <= rsi_buy2: stage = 2 + elif rsi <= rsi_buy1: stage = 1 + if stage == 0: + continue + ratios = [buy1_ratio, buy2_ratio, buy3_ratio] + ratio = ratios[stage - 1] + if ratio <= 0: + continue # buy3_ratio=0이면 3단계 진입 안 함 + invest = slot_money * ratio + qty = max(1, int(invest / next_open)) + cost = next_open * qty * (1 + fee_rate) + position = { + "avg": next_open, + "qty": qty, + "cost": cost, + "stage": stage, + "entry_i": i + 1, + "peak": next_open, + "trend_mode": False, + "ath_drop": round(_drop(ath_arr[i]), 1), + "year_drop": round(_drop(year_high_arr[i]), 1), + "w52_drop": round(_drop(w52_high_arr[i]), 1), + } + + # ─── 미청산 포지션 마지막 봉 강제 청산 ────────────────────────── + if position is not None and len(candles) > 0: + 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": "기간종료", + "rsi_sell": None, + "ath_drop": position.get("ath_drop", 0), + "year_drop": position.get("year_drop", 0), + "w52_drop": position.get("w52_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] + total_pnl = sum(t["pnl"] for t in trades) + avg_hold = sum(t["hold_days"] for t in trades) / total if total else 0 + + win_pnl = sum(t["pnl"] for t in wins) + loss_pnl = sum(t["pnl"] for t in losses) + pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0 + + peak, mdd, cum = 0.0, 0.0, 0.0 + for t in trades: + cum += t["pnl"] + peak = max(peak, cum) + mdd = max(mdd, peak - cum) + + # 이유별 집계 + reasons: Dict[str, int] = {} + for t in trades: + r = t["reason"] + prefix = r.split("(")[0] # "익절", "RSI과열", "손절", "기간종료" + reasons[prefix] = reasons.get(prefix, 0) + 1 + + # ── Buy & Hold 벤치마크 ────────────────────────────────────────────── + # 첫 봉 종가 매수 → 마지막 봉 종가 매도 (수수료·세금 제외 단순 계산) + bnh_pct = round((closes[-1] - closes[0]) / closes[0] * 100, 2) if closes[0] > 0 else 0.0 + bnh_pnl = round(slot_money * bnh_pct / 100) + # 봇 수익률: 투자 원금(slot_money) 대비 실현 손익 + bot_pct = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0 + # 알파 = 봇 수익률 - Buy & Hold 수익률 (양수면 봇이 시장 이김) + alpha_pct = round(bot_pct - bnh_pct, 2) + + return { + "summary": { + "total_trades": total, + "win_trades": len(wins), + "loss_trades": len(losses), + "win_rate": round(len(wins) / total * 100, 1) if total else 0, + "total_pnl": round(total_pnl), + "avg_hold_days": round(avg_hold, 1), + "profit_factor": round(min(pf, 9999.0), 2), + "max_drawdown": round(mdd), + # Buy & Hold 비교 + "bnh_pct": bnh_pct, # 기간 동안 그냥 들고 있었을 때 수익률(%) + "bnh_pnl": bnh_pnl, # 그냥 들고 있었을 때 손익(원) + "bot_pct": bot_pct, # 봇 수익률(투자금 대비 %) + "alpha_pct": alpha_pct, # 초과수익(봇 - Buy&Hold, %p) + }, + "equity": equity, + "reasons": reasons, + "trades": trades[-200:], + } + +# ───────────────────────────────────────────────────────────────────────────── +# 파라미터 Grid Search (단일 종목) +# ───────────────────────────────────────────────────────────────────────────── +def run_param_search( + candles: List[Dict], + grid: Optional[Dict] = None, + min_trades: int = 2, + base_cfg: Optional[Dict] = None, # 사용자가 설정한 현재 파라미터 (없으면 DEFAULT 사용) +) -> List[Dict]: + """ + 종목 하나에 대해 파라미터 Grid Search 실행 + 반환: 수익 기준 내림차순 정렬된 결과 리스트 + + base_cfg: 탐색 그리드에 없는 파라미터들의 기준값. + 웹 카드에서 사용자가 직접 설정한 값을 전달하면 + rsi_sell, rsi_period, rsi_buy3 등이 그 값으로 고정됨. + None 이면 DEFAULT_STOCK_CONFIG 사용. + """ + from itertools import product as iproduct + + if grid is None: + grid = { + # ── MA 추세 판단 ────────────────────────────────────────────── + "ma_fast": [15, 20, 30], + "ma_slow": [40, 60, 120], + # ── 샹들리에 배수: 추세장 청산 핵심 파라미터 ──────────────────── + # 작을수록 타이트(빨리 청산) / 클수록 여유(오래 홀딩) + "atr_mult": [2.0, 3.0, 4.0], + # 퍼센트 보조 방어선 (0=비활성, ATR선이 더 낮을 때만 활성화) + "trail_stop_pct": [0.0, 8.0], + # ── 진입 RSI 눌림목 ──────────────────────────────────────────── + "rsi_buy1": [60, 55, 50, 45], + "rsi_buy2": [50, 45, 40, 35], + # ── 횡보장 익절/손절 기준 (추세장엔 무시됨) ──────────────────── + "take_profit_pct": [15.0, 30.0], + "stop_loss_pct": [7.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])) + + results = [] + # base_cfg가 주어지면 사용자 설정값 우선, 없으면 DEFAULT 사용 + _base = dict(DEFAULT_STOCK_CONFIG) + if base_cfg: + _base.update(base_cfg) + + for vals in combos: + cfg = dict(_base) + cfg.update(dict(zip(keys, vals))) + # MA 단기 < 장기 조건 보정 + if cfg["ma_fast"] >= cfg["ma_slow"]: + continue + # RSI buy1 > buy2 조건 보정 + if cfg["rsi_buy1"] <= cfg["rsi_buy2"]: + continue + res = 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"], + }) + + results.sort(key=lambda x: x["total_pnl"], reverse=True) + return results + + +# ───────────────────────────────────────────────────────────────────────────── +# 실매매 봇 (HoldingTrader) +# ───────────────────────────────────────────────────────────────────────────── +class HoldingTrader: + """ + 장기 홀딩 실매매 봇 + - active_trades 에 strategy='HOLDING' 으로 기록 → 다른 봇과 계좌 분리 + - 종목별 파라미터는 holding_stock_config 에서 조회 + - 10분 주기로 RSI 체크 → 3단계 분할매수 / 익절·손절 매도 + - 진입가: 시장가 주문 (장중 호가 추종) + """ + + STRATEGY = "HOLDING" # active_trades.strategy 값 — 다른 봇과 충돌 방지 + BOT_NAME = "홀딩봇" + + def __init__(self): + self.db = TradeDB() + ensure_holding_tables(self.db) + + # 실전/모의 토큰 모두 최신 상태로 유지 (모드와 무관하게 양쪽 갱신) + try: + from kis_token_manager import ensure_both_tokens + ensure_both_tokens() + except Exception as _te: + logger.warning(f"토큰 자동갱신 건너뜀: {_te}") + + cfg_row = self.db.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + r = dict(cfg_row) if cfg_row else {} + + def safe(v): return v if v else "" + + is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes") + self.mock = is_mock + self.app_key = safe(r.get("KIS_APP_KEY_MOCK" if is_mock else "KIS_APP_KEY_REAL")) + self.app_secret = safe(r.get("KIS_APP_SECRET_MOCK" if is_mock else "KIS_APP_SECRET_REAL")) + self.base_url = ( + "https://openapivts.koreainvestment.com:29443" + if is_mock else + "https://openapi.koreainvestment.com:9443" + ) + self.account_no = safe(r.get("KIS_ACCOUNT_NO_MOCK" if is_mock else "KIS_ACCOUNT_NO_REAL")) + self.account_code = safe(r.get("KIS_ACCOUNT_CODE_MOCK" if is_mock else "KIS_ACCOUNT_CODE_REAL")) or "01" + + # 알림 설정 + self.mm_server = safe(r.get("MM_SERVER_URL", "")) or "https://mattermost.hoonfam.org" + self.mm_token = safe(r.get("MM_BOT_TOKEN_", "")) + self.mm_channel = safe(r.get("KIS_LONG_MM_CHANNEL", "")) or "stock" + self.tg_token = safe(r.get("MM_BOT_TOKEN_", "")) # TG 토큰 있으면 별도로 + self.tg_chat = safe(r.get("KIS_LONG_MM_CHANNEL", "")) + + self.watchlist = load_watchlist() + logger.info(f"✅ {self.BOT_NAME} 초기화 | 모의={is_mock} | 종목={len(self.watchlist)}개") + + # ──────────────────── 알림 ──────────────────── + def _notify(self, title: str, body: str): + msg = f"**[{self.BOT_NAME}]** {title}\n{body}" + if self.mm_token and self.mm_server: + try: + requests.post( + f"{self.mm_server}/hooks/{self.mm_token}", + json={"text": msg}, timeout=5 + ) + except Exception as e: + logger.debug(f"알림 전송 실패: {e}") + + # ──────────────────── KIS REST ──────────────────── + def _token(self) -> str: + return _ensure_token(self.mock) + + def _get(self, path: str, params: dict) -> dict: + """GET 요청 (429 재시도)""" + url = f"{self.base_url}{path}" + token = self._token() + headers = { + "Content-Type": "application/json", + "authorization": f"Bearer {token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + } + for attempt in range(3): + try: + r = requests.get(url, headers=headers, params=params, timeout=10) + if r.status_code == 429: + time.sleep((attempt + 1) * 2) + continue + r.raise_for_status() + return r.json() + except Exception as e: + if attempt == 2: + raise + time.sleep(2) + raise RuntimeError("GET 요청 실패") + + def _post(self, path: str, tr_id: str, data: dict) -> dict: + """POST 요청 (주문용, 429 재시도)""" + url = f"{self.base_url}{path}" + token = self._token() + headers = { + "Content-Type": "application/json", + "authorization": f"Bearer {token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + } + for attempt in range(3): + try: + r = requests.post(url, headers=headers, json=data, timeout=10) + if r.status_code == 429: + time.sleep((attempt + 1) * 2) + continue + r.raise_for_status() + return r.json() + except Exception as e: + if attempt == 2: + raise + time.sleep(2) + raise RuntimeError("POST 요청 실패") + + def get_price(self, code: str) -> Optional[Dict]: + """ + 현재가 + 당일 고/저, 52주 고/저, 등락률 조회 (FHKST01010100) + 반환 dict 키: + price - 현재가 + day_high - 당일 고가 + day_low - 당일 저가 + w52_high - 52주 최고가 + w52_low - 52주 최저가 + change_rate - 전일대비 등락률(%) + volume - 누적 거래량 + """ + try: + headers = { + "Content-Type": "application/json", + "authorization": f"Bearer {self._token()}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": "FHKST01010100", + } + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code} + r = requests.get( + f"{self.base_url}/uapi/domestic-stock/v1/quotations/inquire-price", + headers=headers, params=params, timeout=10 + ) + out = r.json().get("output", {}) + price = float(out.get("stck_prpr", 0) or 0) + if price <= 0: + return None + return { + "price": price, + "day_high": float(out.get("stck_hgpr", 0) or 0), # 당일 고가 + "day_low": float(out.get("stck_lwpr", 0) or 0), # 당일 저가 + "w52_high": float(out.get("w52_hgpr", 0) or 0), # 52주 최고가 + "w52_low": float(out.get("w52_lwpr", 0) or 0), # 52주 최저가 + "change_rate": float(out.get("prdy_ctrt", 0) or 0), # 등락률(%) + "volume": int(out.get("acml_vol", 0) or 0), # 누적 거래량 + } + except Exception as e: + logger.warning(f"현재가 조회 실패 ({code}): {e}") + return None + + def _order(self, code: str, qty: int, is_buy: bool) -> bool: + """시장가 주문 (매수/매도)""" + if qty <= 0: + return False + # 모의: VTTC0802U(매수) / VTTC0801U(매도) 실전: TTTC0802U / TTTC0801U + if is_buy: + tr_id = "VTTC0802U" if self.mock else "TTTC0802U" + else: + tr_id = "VTTC0801U" if self.mock else "TTTC0801U" + try: + data = { + "CANO": self.account_no, + "ACNT_PRDT_CD": self.account_code, + "PDNO": code, + "ORD_DVSN": "01", # 시장가 + "ORD_QTY": str(qty), + "ORD_UNPR": "0", + } + result = self._post( + "/uapi/domestic-stock/v1/trading/order-cash", tr_id, data + ) + ok = result.get("rt_cd") == "0" + if not ok: + logger.error(f"주문 실패 ({code} {'매수' if is_buy else '매도'}): {result.get('msg1','')}") + return ok + except Exception as e: + logger.error(f"주문 예외 ({code}): {e}") + return False + + # ──────────────────── DB 포지션 ──────────────────── + def _get_position(self, code: str) -> Optional[dict]: + """HOLDING 전략 포지션 조회""" + row = self.db.conn.execute( + "SELECT * FROM active_trades WHERE code=%s AND strategy=%s", + [code, self.STRATEGY] + ).fetchone() + return dict(row) if row else None + + def _upsert_position(self, code: str, name: str, avg_price: float, + qty: int, invested: float, stage: int, + is_uptrend: bool = False): + """ + 포지션 저장/갱신 (strategy='HOLDING'). + status = T{stage}(추세장 진입) or S{stage}(횡보장 진입) + → 봇 재시작 후에도 청산 모드(T/S)를 구분하여 샹들리에/고정익절 적용 + """ + now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + mode_prefix = "T" if is_uptrend else "S" # T=Trend / S=Sideways + self.db.conn.execute( + """INSERT INTO active_trades + (code, name, strategy, avg_buy_price, current_qty, target_qty, + total_invested, status, buy_date, updated_at) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE + avg_buy_price=VALUES(avg_buy_price), + current_qty=VALUES(current_qty), + target_qty=VALUES(target_qty), + total_invested=VALUES(total_invested), + status=VALUES(status), + updated_at=VALUES(updated_at)""", + [code, name, self.STRATEGY, round(avg_price, 2), qty, qty, + round(invested, 2), f"{mode_prefix}{stage}", now, now] + ) + self.db.conn.commit() + + def _delete_position(self, code: str): + """포지션 삭제 (청산 후)""" + self.db.conn.execute( + "DELETE FROM active_trades WHERE code=%s AND strategy=%s", + [code, self.STRATEGY] + ) + self.db.conn.commit() + + # ──────────────────── RSI 계산 ──────────────────── + def _get_rsi(self, code: str, cfg: dict, current_price: float) -> Optional[float]: + """ + 저장된 일봉 + 당일 현재가(가중치 없이 단순 추가)로 RSI 계산. + 장중에도 '오늘의 종가가 이 가격이라면' 의 RSI를 반환. + """ + period = int(cfg.get("rsi_period", 14)) + candles = get_stored_candles(self.db, code) + if len(candles) < period + 2: + return None + closes = [float(c["close"]) for c in candles] + # 오늘 데이터가 DB에 없으면 현재가로 추가 + today_str = datetime.now().strftime("%Y-%m-%d") + if str(candles[-1]["candle_date"])[:10] != today_str: + closes.append(float(current_price)) + rsis = _rsi_series(closes, period) + for r in reversed(rsis): + if r is not None: + return r + return None + + # ──────────────────── 매수 로직 ──────────────────── + def _try_buy(self, code: str, name: str, current_price: float, + rsi: float, cfg: dict, ctx: Optional[Dict] = None, + is_uptrend: bool = False): + """ + 분할매수 체크 및 실행. + is_uptrend: 추세 상승장 여부 → status에 T/S 기록해 청산 모드 구분 + ctx: _calc_context_signals() 반환값 (ATH·연도고점·52주 낙폭 필터) + """ + rsi_buy1 = float(cfg.get("rsi_buy1", 45)) + rsi_buy2 = float(cfg.get("rsi_buy2", 40)) + rsi_buy3 = float(cfg.get("rsi_buy3", 35)) + b1 = float(cfg.get("buy1_ratio", 30)) / 100 + b2 = float(cfg.get("buy2_ratio", 30)) / 100 + b3 = float(cfg.get("buy3_ratio", 40)) / 100 + slot = float(cfg.get("slot_money", 3_000_000)) + ath_min = float(cfg.get("ath_drop_min_pct", 0.0)) + year_min = float(cfg.get("year_drop_min_pct", 0.0)) + w52_min = float(cfg.get("w52_drop_min_pct", 0.0)) + + pos = self._get_position(code) + cur_status = (pos["status"] or "S0") if pos else "S0" + cur_stage = int(cur_status[1]) if len(cur_status) > 1 and cur_status[1].isdigit() else 0 + + # ─── 매수 단계 및 비중 결정 ──────────────────────────────────────── + target_stage, ratio = 0, 0.0 + if cur_stage == 0 and rsi <= rsi_buy1: + target_stage, ratio = 1, b1 + elif cur_stage == 1 and rsi <= rsi_buy2: + target_stage, ratio = 2, b2 + elif cur_stage == 2 and rsi <= rsi_buy3: + target_stage, ratio = 3, b3 + if target_stage == 0: + return # RSI 조건 미충족 + + # ─── 컨텍스트 낙폭 필터 ──────────────────────────────────────────── + if ctx: + if ath_min > 0 and ctx["ath_drop_pct"] < ath_min: + logger.info(f"[{code}] ATH 낙폭 부족 ({ctx['ath_drop_pct']:.1f}% < {ath_min}%) → 매수 보류") + return + if year_min > 0 and ctx["year_drop_pct"] < year_min: + logger.info(f"[{code}] 연도 낙폭 부족 ({ctx['year_drop_pct']:.1f}% < {year_min}%) → 매수 보류") + return + if w52_min > 0 and ctx["w52_drop_pct"] < w52_min: + logger.info(f"[{code}] 52주 낙폭 부족 ({ctx['w52_drop_pct']:.1f}% < {w52_min}%) → 매수 보류") + return + + invest = slot * ratio + qty = max(1, int(invest / current_price)) + cost = current_price * qty + + mode_str = "📈추세" if is_uptrend else "📉역추세" + logger.info(f"[{code}] {mode_str} 매수 RSI={rsi:.1f} → {target_stage}차 {qty}주 @ {current_price:,.0f}원") + + if not self._order(code, qty, is_buy=True): + return + + # 평단가 갱신 + if pos: + prev_cost = float(pos["avg_buy_price"]) * int(pos["current_qty"]) + prev_qty = int(pos["current_qty"]) + new_qty = prev_qty + qty + new_avg = (prev_cost + cost) / new_qty + new_inv = float(pos["total_invested"]) + cost + else: + new_qty = qty + new_avg = current_price + new_inv = cost + + self._upsert_position(code, name, new_avg, new_qty, new_inv, target_stage, is_uptrend) + + # 컨텍스트 낙폭 정보 알림에 포함 + ctx_txt = "" + if ctx: + ctx_txt = ( + f"\n▪ ATH 낙폭: {ctx['ath_drop_pct']:.1f}%" + f" | 연도: {ctx['year_drop_pct']:.1f}%" + f" | 52주: {ctx['w52_drop_pct']:.1f}%" + ) + pnl_txt = f"평단가: {new_avg:,.0f}원 | 수량: {new_qty}주" + self._notify( + f"📥 {name}({code}) {target_stage}차 매수", + f"▪ 체결가: {current_price:,.0f}원\n" + f"▪ 수량: {qty}주 ({cost:,.0f}원)\n" + f"▪ {pnl_txt}\n" + f"▪ RSI: {rsi:.1f} (기준: {[rsi_buy1,rsi_buy2,rsi_buy3][target_stage-1]})" + f"{ctx_txt}" + ) + + # ──────────────────── 매도 로직 ──────────────────── + def _try_sell(self, code: str, name: str, current_price: float, + rsi: float, cfg: dict, + is_deadcross: bool = False, + peak_price: float = 0.0, + atr_val: float = 0.0): + """ + 익절/손절 체크 및 실행. + + [추세장 진입(status=T*)] + → 샹들리에 엑시트(peak − ATR × mult) + 데드크로스만 청산 + → 고정 익절·RSI과열 무시: 추세 끝까지 추적 + [횡보장 진입(status=S*)] + → 기존 방식: 고정 익절% / RSI과열 / 손절% + """ + pos = self._get_position(code) + if not pos: + return + + avg = float(pos["avg_buy_price"]) + qty = int(pos["current_qty"]) + pnl_r = (current_price - avg) / avg if avg > 0 else 0.0 + cur_status = (pos.get("status") or "S1") + is_trend_mode = cur_status.startswith("T") # T=추세장, S=횡보장 + + tp_pct = float(cfg.get("take_profit_pct", 15.0)) / 100 + sl_pct = float(cfg.get("stop_loss_pct", 10.0)) / 100 + rsi_sell_thr = float(cfg.get("rsi_sell", 75.0)) + trail_stop_p = float(cfg.get("trail_stop_pct", 8.0)) + atr_mult = float(cfg.get("atr_mult", 3.0)) + + sell_reason = None + + if is_trend_mode: + # ─── 추세장: 고정 익절·RSI과열 무시 → 추세 끝까지 추적 ────────── + if is_deadcross: + sell_reason = f"데드크로스({pnl_r*100:+.1f}%)" + elif peak_price > 0 and atr_val > 0: + chandelier = peak_price - atr_val * atr_mult + if trail_stop_p > 0: + pct_trail = peak_price * (1.0 - trail_stop_p / 100.0) + chandelier = max(chandelier, pct_trail) + if current_price <= chandelier: + sell_reason = f"샹들리에({pnl_r*100:+.1f}%)" + # 손절은 추세장에서도 유지 (치명적 손실 방지) + if sell_reason is None and pnl_r <= -sl_pct: + sell_reason = f"손절({pnl_r*100:.1f}%)" + else: + # ─── 횡보장: 기존 방식 철저히 준수 ────────────────────────────── + if pnl_r >= tp_pct: + sell_reason = f"익절(+{pnl_r*100:.1f}%)" + elif rsi and rsi >= rsi_sell_thr: + sell_reason = f"RSI과열({rsi:.1f})" + elif pnl_r <= -sl_pct: + sell_reason = f"손절({pnl_r*100:.1f}%)" + + if not sell_reason: + return + + logger.info(f"[{code}] 매도 신호: {sell_reason} | {qty}주 @ {current_price:,.0f}원 " + f"(peak:{peak_price:,.0f} atr:{atr_val:,.0f})") + + if not self._order(code, qty, is_buy=False): + return + + fee = current_price * qty * (0.015 / 100 + 0.18 / 100) + pnl = (current_price - avg) * qty - fee + self._delete_position(code) + + self._notify( + f"📤 {name}({code}) 매도 | {sell_reason}", + f"▪ 체결가: {current_price:,.0f}원\n" + f"▪ 수량: {qty}주\n" + f"▪ 평단가: {avg:,.0f}원\n" + f"▪ 최고가: {peak_price:,.0f}원 | ATR: {atr_val:,.0f}\n" + f"▪ 손익: {pnl:+,.0f}원\n" + f"▪ 수익률: {pnl_r*100:+.2f}%" + ) + + # ──────────────────── 메인 루프 ──────────────────── + def _is_market_hour(self) -> bool: + """장 시간 체크 (09:00~15:30 평일)""" + now = datetime.now() + if now.weekday() >= 5: # 토·일 + return False + t = now.hour * 100 + now.minute + return 900 <= t <= 1530 + + def _fetch_today_candles(self): + """ + 장 마감 직후 오늘 일봉을 자동 수집하여 DB 갱신. + KIS API는 당일 종가가 확정되는 15:30 이후 조회 가능. + """ + today = datetime.now().strftime("%Y-%m-%d") + app_key, app_secret, base_url, mock = _get_kis_token(self.db) + logger.info(f"📥 장 마감 후 자동 캔들 수집 ({today})") + total_saved = 0 + for item in self.watchlist: + code, name = item["code"], item["name"] + try: + rows = fetch_daily_ohlcv(code, today, today, + app_key, app_secret, base_url, mock=mock) + saved = store_candles(self.db, code, rows) + total_saved += saved + logger.info(f" [{code}] {name}: {saved}봉 저장") + time.sleep(0.3) + except Exception as e: + logger.error(f" [{code}] 캔들 자동 수집 오류: {e}") + logger.info(f"✅ 자동 캔들 수집 완료 (총 {total_saved}봉 저장)") + self._notify( + "📊 일봉 자동 수집 완료", + f"{today} 종가 기준 {len(self.watchlist)}종목 수집 ({total_saved}봉 저장)" + ) + + def run(self): + logger.info(f"\n{'='*50}") + logger.info(f" {self.BOT_NAME} 시작 | 모의={self.mock}") + logger.info(f" 종목: {[i['name'] for i in self.watchlist]}") + logger.info(f"{'='*50}\n") + self._notify("🚀 봇 시작", f"종목 {len(self.watchlist)}개 모니터링 시작") + + _candles_fetched_date = "" # 오늘 자동수집 완료 날짜 (중복 방지) + + while True: + try: + now = datetime.now() + t = now.hour * 100 + now.minute + + # ── 장 마감 후 자동 일봉 수집 (15:35~15:50, 하루 1회) ── + today_str = now.strftime("%Y-%m-%d") + if (now.weekday() < 5 + and 1535 <= t <= 1550 + and _candles_fetched_date != today_str): + self._fetch_today_candles() + _candles_fetched_date = today_str + + if not self._is_market_hour(): + logger.info("⏳ 장 외 시간 대기...") + time.sleep(60) + continue + + for item in self.watchlist: + code = item["code"] + name = item["name"] + try: + cfg = get_stock_config(self.db, code) + pi = self.get_price(code) # price info dict + if not pi: + continue + price = pi["price"] + + # DB 일봉 기반 컨텍스트 신호 (ATH·연도고점·52주 고저) + stored = get_stored_candles(self.db, code) + ctx = _calc_context_signals(stored, price) + + rsi = self._get_rsi(code, cfg, price) + pos = self._get_position(code) + + # ── 실시간 MA / ATR / Peak 계산 ────────────────────── + is_uptrend = False + is_deadcross = False + peak_price = price + atr_val = price * 0.05 # fallback: 현재가의 5% + + if len(stored) >= 30: + _cl = [float(c["close"]) for c in stored] + _hi = [float(c["high"]) for c in stored] + _lo = [float(c["low"]) for c in stored] + # 오늘 실시간 봉 추가 (당일 고·저·종가로 지표 갱신) + _cl.append(price); _hi.append(pi["day_high"]) + _lo.append(pi["day_low"]) + + maf_p = int(cfg.get("ma_fast", 20)) + mas_p = int(cfg.get("ma_slow", 60)) + atr_p = int(cfg.get("atr_period", 14)) + tf = float(cfg.get("trend_filter", 1.0)) >= 0.5 + + maf_a = _ma_series(_cl, maf_p) + mas_a = _ma_series(_cl, mas_p) + atr_a = _atr_series(_hi, _lo, _cl, atr_p) + + mf = maf_a[-1]; ms = mas_a[-1] + mfp = maf_a[-2] if len(maf_a) > 1 else mf + msp = mas_a[-2] if len(mas_a) > 1 else ms + + if atr_a[-1] is not None: + atr_val = atr_a[-1] + if tf and mf and ms: + is_uptrend = mf > ms + is_deadcross = (mf < ms and mfp is not None + and msp is not None and mfp >= msp) + + # ── 보유 시 진입일 이후 최고가(Peak) 추적 ──────────── + if pos: + buy_d = str(pos.get("buy_date", ""))[:10] + if buy_d: + post = [c for c in stored if str(c["candle_date"])[:10] >= buy_d] + highs = [float(c["high"]) for c in post] + [pi["day_high"], price] + peak_price = max(highs) if highs else price + + logger.info( + f"[{code}] {name} | " + f"현재가:{price:,.0f}({pi['change_rate']:+.2f}%) | " + f"ATH낙폭:{ctx['ath_drop_pct']:.1f}% " + f"연도:{ctx['year_drop_pct']:.1f}% | " + f"RSI:{f'{rsi:.1f}' if rsi else 'N/A'} | " + f"추세:{'상승' if is_uptrend else '하락/횡보'} | " + f"ATR:{atr_val:,.0f} | " + f"포지션:{'있음(' + pos['status'] + ')' if pos else '없음'}" + ) + + if pos: + self._try_sell(code, name, price, rsi, cfg, + is_deadcross=is_deadcross, + peak_price=peak_price, + atr_val=atr_val) + else: + self._try_buy(code, name, price, rsi, cfg, ctx, + is_uptrend=is_uptrend) + + except Exception as e: + logger.error(f"[{code}] 처리 중 오류: {e}") + time.sleep(3) + + sleep_sec = random.uniform(540, 660) # 9~11분 랜덤 대기 + logger.info(f"💤 {sleep_sec/60:.1f}분 후 다음 체크...") + time.sleep(sleep_sec) + + except KeyboardInterrupt: + logger.info("🛑 사용자 중단") + self._notify("🛑 봇 중단", "사용자가 종료했습니다.") + break + except Exception as e: + logger.error(f"메인 루프 오류: {e}") + time.sleep(60) + + self.db.close() + + +# ───────────────────────────────────────────────────────────────────────────── +# CLI: 캔들 수집 / 백테스트 실행 +# ───────────────────────────────────────────────────────────────────────────── +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="홀딩 전략 봇") + sub = parser.add_subparsers(dest="cmd") + + p_fetch = sub.add_parser("fetch", help="일봉 캔들 수집") + p_fetch.add_argument("--start", default="2023-01-01", help="시작일 YYYY-MM-DD") + p_fetch.add_argument("--end", default=datetime.now().strftime("%Y-%m-%d")) + + p_bt = sub.add_parser("backtest", help="백테스트 실행") + p_bt.add_argument("--code", default="", help="종목코드 (공백=전체)") + p_bt.add_argument("--start", default="2023-01-01") + p_bt.add_argument("--end", default=datetime.now().strftime("%Y-%m-%d")) + + sub.add_parser("live", help="실매매 봇 시작") + + args = parser.parse_args() + + if args.cmd == "live": + # 실매매 봇 시작 — DB는 HoldingTrader 내부에서 관리 + trader = HoldingTrader() + trader.run() + sys.exit(0) + + db = TradeDB() + ensure_holding_tables(db) + + if args.cmd == "fetch": + items = load_watchlist() + app_key, app_secret, base_url, mock = _get_kis_token(db) + for item in items: + code, name = item["code"], item["name"] + logger.info(f" [{code}] {name} 일봉 수집 ({args.start} ~ {args.end})") + rows = fetch_daily_ohlcv(code, args.start, args.end, app_key, app_secret, base_url, mock=mock) + saved = store_candles(db, code, rows) + logger.info(f" → {saved}봉 저장 완료") + time.sleep(0.5) + + elif args.cmd == "backtest": + items = load_watchlist() + if args.code: + items = [i for i in items if i["code"] == args.code] + for item in items: + code = item["code"] + candles = get_stored_candles(db, code, args.start, args.end) + cfg = get_stock_config(db, code) + result = run_backtest(candles, cfg) + s = result.get("summary", {}) + print(f"\n[{code}] {item['name']}") + print(f" 거래:{s.get('total_trades')} | 승률:{s.get('win_rate')}% | " + f"손익:{s.get('total_pnl'):+,}원 | PF:{s.get('profit_factor')} | " + f"MDD:{s.get('max_drawdown'):,}원") + + else: + parser.print_help() + + db.close() diff --git a/kis_api.htm b/kis_api.htm new file mode 100644 index 0000000..85befb1 --- /dev/null +++ b/kis_api.htm @@ -0,0 +1,2678 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <body> + <p>이 페이지에는 프레임이 있지만 사용 중인 브라우저에서 프레임을 지원하지 않습니다.</p> + </body> + + + diff --git a/kis_backtest_web.service b/kis_backtest_web.service new file mode 100644 index 0000000..2e2808d --- /dev/null +++ b/kis_backtest_web.service @@ -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 diff --git a/kis_holding_ver1.py b/kis_holding_ver1.py new file mode 100644 index 0000000..0f5d9bc --- /dev/null +++ b/kis_holding_ver1.py @@ -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() diff --git a/kis_long_ver1.py b/kis_long_ver1.py index f83c6a1..8d2a09b 100644 --- a/kis_long_ver1.py +++ b/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) diff --git a/kis_long_ver2.py b/kis_long_ver2.py new file mode 100644 index 0000000..9870739 --- /dev/null +++ b/kis_long_ver2.py @@ -0,0 +1,1008 @@ +""" +KIS Long Watch Bot Ver2 - 위시리스트/늘림목 관찰 전용 리포트 봇 +- 한국투자증권(KIS) Open API 사용 (kis_long_ver1의 KISClientWithOrder 재사용) +- 매매는 하지 않고, 보유/관심 종목을 모니터링해서 리포트와 뉴스 기반 알림만 전송 +- linux_bot 형식 장기 투자 체크 리포트 (💰📊📈💼🎯🤖 + AI 4줄) 장 시작/마감 전송 +- 수치는 전부 DB/env에서 로드 (하드코딩 금지) +""" + +import asyncio +import logging +import random +import time +from datetime import datetime as dt, timedelta +from pathlib import Path +from typing import Dict, List, Optional + +import pandas as pd + +from database import TradeDB +from news_analyzer import NewsAnalyzer +from kis_long_ver1 import ( + KISClientWithOrder, + MattermostBot, + get_env_float, + get_env_int, + get_env_bool, + get_env_from_db, + get_kis_daily_chart, + get_kis_per_eps_peg, + calculate_volatility, + MM_CHANNEL_LONG, + MM_CHANNEL_DEFAULT, + SCRIPT_DIR, + db as shared_db, +) + +# Gemini AI (4줄 인사이트 생성용) +try: + from kis_long_ver1 import gemini_model +except Exception: + gemini_model = None + +# 로깅 설정 +logging.basicConfig( + format="[%(asctime)s] %(message)s", + datefmt="%H:%M:%S", + level=logging.INFO, +) +logger = logging.getLogger("KISLongBotV2") + + +class LongWatchBotV2: + """ + 위시리스트/보유 종목을 기반으로 장기 관찰 리포트와 뉴스 알림을 보내는 봇. + - KIS API로 현재가/일봉 차트 조회 (6개월 등, env로 조절) + - long_term_watchlist.json + LONG 전략 보유 종목 기준 + - 매수/매도 주문은 하지 않음 (완전 관찰 전용) + """ + + def __init__(self): + # DB는 기존 공유 인스턴스를 재사용 (kis_long_ver1과 동일) + self.db: TradeDB = shared_db + + # KIS 읽기 전용 클라이언트 (주문 메서드는 사용하지 않음) + self.client = KISClientWithOrder() + + # Mattermost + self.mm = MattermostBot() + self.mm_channel = MM_CHANNEL_LONG or MM_CHANNEL_DEFAULT + + # 위시리스트 파일 + self.watchlist_path: Path = SCRIPT_DIR / "long_term_watchlist.json" + self.watchlist: List[Dict] = self._load_watchlist() + + # DB에 이미 LONG 보유 중인 종목 (늘림목 포지션) + self.holdings: Dict[str, Dict] = {} + active_trades = self.db.get_active_trades(strategy_prefix="LONG") + for code, trade in active_trades.items(): + qty = trade.get("current_qty", 0) or 0 + if qty <= 0: + continue + self.holdings[code] = { + "code": code, + "name": trade.get("name", code), + "avg_price": trade.get("avg_buy_price", 0), + "qty": qty, + "first_buy_date": trade.get("buy_date", ""), + } + + # 자산·리포트용 상태 + self.current_cash: float = 0 + self.current_total_asset: float = 0 + self.start_day_asset: float = 0 + self.total_deposit: float = get_env_float("TOTAL_DEPOSIT", 0) + self.today_date: str = dt.now().strftime("%Y-%m-%d") + + # 뉴스 분석기 (ANTHROPIC_API_KEY 필요, 없으면 내부에서 비활성) + self.news_analyzer = NewsAnalyzer() + + # 리포트/뉴스 스케줄 설정 (전부 env/DB에서 로드) + self.daily_lookback_days: int = get_env_int("LONG_DAILY_LOOKBACK_DAYS", 180) + self.ma_short_days: int = get_env_int("LONG_MA_SHORT_DAYS", 5) + self.ma_long_days: int = get_env_int("LONG_MA_LONG_DAYS", 20) + + self.report_am_hour: int = get_env_int("LONG_REPORT_AM_HOUR", 9) + self.report_am_min: int = get_env_int("LONG_REPORT_AM_MIN", 5) + self.report_pm_hour: int = get_env_int("LONG_REPORT_PM_HOUR", 15) + self.report_pm_min: int = get_env_int("LONG_REPORT_PM_MIN", 35) + + self.news_enabled: bool = get_env_bool("LONG_NEWS_ENABLED", True) + self.news_interval_min: int = get_env_int("LONG_NEWS_INTERVAL_MIN", 60) + self.news_active_start_hour: int = get_env_int("LONG_NEWS_ACTIVE_START_HOUR", 9) + self.news_active_end_hour: int = get_env_int("LONG_NEWS_ACTIVE_END_HOUR", 23) + self.news_max_count: int = get_env_int("NEWS_MAX_COUNT", 5) + + # 종목별 분석 사이 딜레이 (초당 호출 분산용, 기본 1~2초) + self.analysis_delay_min: float = get_env_float("LONG_ANALYSIS_DELAY_MIN_SEC", 1.0) + self.analysis_delay_max: float = get_env_float("LONG_ANALYSIS_DELAY_MAX_SEC", 2.0) + if self.analysis_delay_min < 0: + self.analysis_delay_min = 0.0 + if self.analysis_delay_max < self.analysis_delay_min: + self.analysis_delay_max = self.analysis_delay_min + + # 플래그 + self.morning_report_sent: bool = False + self.closing_report_sent: bool = False + + # ------------------------------------------------------------------ + # 기본 데이터 로드/갱신 + # ------------------------------------------------------------------ + def _load_watchlist(self) -> List[Dict]: + """long_term_watchlist.json에서 관심 종목 리스트 로드.""" + if not self.watchlist_path.exists(): + logger.warning(f"관심 종목 파일 없음: {self.watchlist_path}") + return [] + try: + with open(self.watchlist_path, "r", encoding="utf-8") as f: + data = f.read().strip() + if not data: + return [] + try: + obj = __import__("json").loads(data) + except Exception as e: + logger.error(f"관심 종목 JSON 파싱 실패: {e}") + return [] + items = obj.get("items", []) if isinstance(obj, dict) else [] + codes = [it.get("code") for it in items] + logger.info(f"📂 위시리스트 로드 완료: {len(items)}개 (codes={','.join(filter(None, codes))})") + return items + except Exception as e: + logger.error(f"관심 종목 로드 실패: {e}") + return [] + + def _update_assets(self) -> None: + """계좌 평가 정보 갱신 (예수금 + LONG 보유 종목 시가 평가).""" + try: + balance = self.client.get_account_evaluation() + if not balance: + logger.warning("💵 [롱봇V2] 계좌 평가 응답 없음 → 자산 갱신 스킵") + return + + # 예수금 (output2) + out2 = balance.get("output2") + if isinstance(out2, list) and out2: + out2 = out2[0] + out2 = out2 if isinstance(out2, dict) else {} + ord_psbl = out2.get("ord_psbl_cash") or out2.get("dnca_tot_amt") + self.current_cash = float(str(ord_psbl or 0).replace(",", "").strip()) if ord_psbl is not None else 0 + + # 보유 종목 평가액 (output1) + output1_list = balance.get("output1") or [] + if isinstance(output1_list, dict): + output1_list = [output1_list] + holdings_value = 0.0 + for code, h in self.holdings.items(): + evlu = None + for row in output1_list: + if (row.get("pdno") or "").strip() == code: + evlu = float(row.get("evlu_amt", 0) or 0) + break + if evlu is not None: + holdings_value += evlu + else: + # 계좌 평가 응답에 없으면 현재가로 대략 평가 + try: + price_data = self.client.inquire_price(code) + if price_data: + p = abs(float(price_data.get("stck_prpr", 0) or 0)) + holdings_value += p * (h.get("qty") or h.get("total_qty") or 0) + except Exception as e: + logger.debug(f"보유평가 현재가 조회 실패({code}): {e}") + + self.current_total_asset = self.current_cash + holdings_value + except Exception as e: + logger.error(f"자산 정보 업데이트 실패: {e}") + + # ------------------------------------------------------------------ + # 개별 종목 목록 헬퍼 + # ------------------------------------------------------------------ + def _get_watch_items(self) -> List[Dict]: + """보유(LONG) + 위시리스트를 합친 유니크 종목 목록.""" + items: Dict[str, Dict] = {} + + for code, h in self.holdings.items(): + items[code] = { + "code": code, + "name": h.get("name", code), + "avg_price": h.get("avg_price", 0), + "qty": h.get("qty") or h.get("total_qty") or 0, + "first_buy_date": h.get("first_buy_date", ""), + "source": "HOLDING", + } + + for it in self.watchlist: + code = (it.get("code") or "").strip() + if not code: + continue + if code not in items: + items[code] = { + "code": code, + "name": it.get("name", code), + "avg_price": float(it.get("avg_price", 0) or 0), + "qty": int(it.get("qty", 0) or 0), + "first_buy_date": it.get("first_buy", ""), + "source": "WATCHLIST", + } + else: + items[code]["source"] = "HOLDING+WATCH" + + return list(items.values()) + + # ------------------------------------------------------------------ + # 기존 일봉 분석 (MA·6M 기준 요약용 - 뉴스 필터에서 계속 사용) + # ------------------------------------------------------------------ + def _fetch_daily_analysis(self, code: str) -> Optional[Dict]: + """KIS 일봉 기반 MA·6M 고저 위치 계산 (기존 요약 리포트용).""" + try: + max_days = int(self.daily_lookback_days) if self.daily_lookback_days > 0 else 180 + except Exception: + max_days = 180 + + try: + df = get_kis_daily_chart(self.client, code, max_days=max_days) + except Exception as e: + logger.error(f"일봉 차트 조회 실패({code}): {e}") + return None + + if df is None or df.empty: + logger.warning(f"일봉 데이터 없음({code})") + return None + + try: + if self.ma_short_days > 0: + df["MA_SHORT"] = df["close"].rolling(window=self.ma_short_days).mean() + else: + df["MA_SHORT"] = pd.NA + if self.ma_long_days > 0: + df["MA_LONG"] = df["close"].rolling(window=self.ma_long_days).mean() + else: + df["MA_LONG"] = pd.NA + except Exception as e: + logger.debug(f"MA 계산 실패({code}): {e}") + + last = df.iloc[-1] + close = float(last["close"]) + ma_short = float(last.get("MA_SHORT") or 0) + ma_long = float(last.get("MA_LONG") or 0) + ma20 = float(last.get("MA20") or 0) + + hi_6m = float(df["high"].tail(max_days).max()) + lo_6m = float(df["low"].tail(max_days).min()) + + def _pct(a: float, b: float) -> float: + if b == 0: + return 0.0 + return (a / b - 1.0) * 100.0 + + return { + "last_close": close, + "ma_short": ma_short, + "ma_long": ma_long, + "ma20": ma20, + "pct_vs_ma_short": _pct(close, ma_short) if ma_short > 0 else 0.0, + "pct_vs_ma_long": _pct(close, ma_long) if ma_long > 0 else 0.0, + "pct_vs_ma20": _pct(close, ma20) if ma20 > 0 else 0.0, + "hi_6m": hi_6m, + "lo_6m": lo_6m, + "pct_from_6m_hi": _pct(close, hi_6m) if hi_6m > 0 else 0.0, + "pct_from_6m_lo": _pct(close, lo_6m) if lo_6m > 0 else 0.0, + } + + # ------------------------------------------------------------------ + # linux_bot 형식 장기 투자 체크 리포트용 분석 메서드들 + # ------------------------------------------------------------------ + def _is_domestic_code(self, code: str) -> bool: + """국내 6자리 종목코드 여부.""" + c = (code or "").strip() + return len(c) == 6 and c.isdigit() + + def _compute_rsi(self, df: pd.DataFrame, period: int = 14) -> Optional[float]: + """일봉 close 기준 RSI 계산 (RSI = 과매수/과매도 지표, 0~100).""" + if df is None or df.empty or len(df) < period + 1: + return None + try: + delta = df["close"].diff(1) + gain = delta.where(delta > 0, 0.0).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0.0)).rolling(window=period).mean() + rs = gain / loss.replace(0, float("nan")) + rsi = 100 - (100 / (1 + rs)) + val = float(rsi.iloc[-1]) + return val if pd.notna(val) else None + except Exception as e: + logger.debug(f"RSI 계산 실패: {e}") + return None + + def _get_trade_history_summary(self, code: str, max_trades: int = 10) -> Optional[Dict]: + """trade_history에서 해당 종목 최근 N건 승률·평균 수익률·주요 매도사유 요약.""" + try: + cursor = self.db.conn.execute( + """ + SELECT profit_rate, realized_pnl, sell_reason, hold_minutes + FROM trade_history + WHERE code = ? + ORDER BY sell_date DESC + LIMIT ? + """, + (code, max_trades), + ) + rows = [dict(zip([c[0] for c in cursor.description], r)) for r in cursor.fetchall()] + except Exception as e: + logger.debug(f"trade_history 조회 실패({code}): {e}") + return None + + if not rows: + return None + + total = len(rows) + profits = [float(r.get("profit_rate") or 0) for r in rows] + wins = len([p for p in profits if p > 0]) + avg_profit = sum(profits) / total if total else 0.0 + + reason_count: Dict[str, int] = {} + for r in rows: + reason = (r.get("sell_reason") or "").strip() or "미기록" + reason_count[reason] = reason_count.get(reason, 0) + 1 + top_reasons = sorted(reason_count.items(), key=lambda x: x[1], reverse=True)[:3] + + return { + "total": total, + "wins": wins, + "losses": total - wins, + "avg_profit": avg_profit, + "top_reasons": top_reasons, + } + + def _full_stock_analysis(self, code: str, item: Dict) -> Optional[Dict]: + """ + 국내 종목 전체 분석: + 가격·수익률·외인/기관·차트(RSI/변동성/정배열)·밸류(PER/PEG)·종합 판정 점수 + """ + name = item.get("name", code) + avg_price = float(item.get("avg_price") or 0) + qty = int(item.get("qty") or 0) + is_holding = qty > 0 and avg_price > 0 + + # 현재가 조회 + try: + price_data = self.client.inquire_price(code) + except Exception as e: + logger.warning(f"현재가 조회 실패({code}): {e}") + return {"code": code, "name": name, "error": "현재가 조회 실패"} + + if not price_data: + return {"code": code, "name": name, "error": "현재가 조회 실패"} + + try: + current_price = abs(float(price_data.get("stck_prpr") or 0)) + prdy_ctrt_raw = (price_data.get("prdy_ctrt") or "0").strip().replace("+", "").replace("%", "") + try: + day_change_pct = float(prdy_ctrt_raw) + except Exception: + day_change_pct = 0.0 + except Exception as e: + logger.warning(f"가격 파싱 실패({code}): {e}") + return {"code": code, "name": name, "error": "가격 파싱 실패"} + + if current_price <= 0: + return {"code": code, "name": name, "error": "현재가 없음"} + + # 수익률 (보유 종목만) + profit_val = (current_price - avg_price) * qty if is_holding else 0.0 + profit_pct = (current_price - avg_price) / avg_price * 100 if is_holding else 0.0 + + # 일봉 차트 + df = pd.DataFrame() + try: + df = get_kis_daily_chart(self.client, code, max_days=max(65, self.daily_lookback_days)) + except Exception as e: + logger.debug(f"일봉 조회 실패({code}): {e}") + + # RSI (과매수: 70+, 과매도: 30-) + rsi = self._compute_rsi(df) if not df.empty else None + # 변동성 (20일 수익률 표준편차 %) + volatility = calculate_volatility(df) if not df.empty else None + # 정배열/역배열 (MA20 > MA60 이면 정배열) + ma20 = float(df["MA20"].iloc[-1]) if not df.empty and "MA20" in df.columns and pd.notna(df["MA20"].iloc[-1]) else None + ma60 = float(df["MA60"].iloc[-1]) if not df.empty and "MA60" in df.columns and pd.notna(df["MA60"].iloc[-1]) else None + if ma20 is not None and ma60 is not None: + ma_arrangement = "정배열" if ma20 > ma60 else "역배열" + else: + ma_arrangement = "데이터없음" + + # PER / PEG (재무비율 API) + per, peg = None, None + try: + fund = get_kis_per_eps_peg(self.client, code, current_price) + if fund: + per = fund.get("per") + peg = fund.get("peg") + time.sleep(random.uniform(0.3, 0.8)) + except Exception as e: + logger.debug(f"PER/PEG 조회 실패({code}): {e}") + + # 외인/기관 동향 (5일 순매수 합산) + investor_text = "데이터 없음" + investor_score = 0 + try: + trend = self.client.get_investor_trend(code, days=5) + if trend is not None: + total_net = trend.get("total_net_buy", 0) or 0 + inv_strong_buy = get_env_float("LONG_INV_STRONG_BUY", 20000.0) + inv_buy = get_env_float("LONG_INV_BUY", 5000.0) + inv_strong_sell = get_env_float("LONG_INV_STRONG_SELL", -20000.0) + inv_sell = get_env_float("LONG_INV_SELL", -5000.0) + if total_net > inv_strong_buy: + investor_text = "외인·기관 강한 매수세" + investor_score = 25 + elif total_net > inv_buy: + investor_text = "외인·기관 소폭 매수" + investor_score = 15 + elif total_net < inv_strong_sell: + investor_text = "외인·기관 강한 매도세" + investor_score = -25 + elif total_net < inv_sell: + investor_text = "외인·기관 소폭 매도" + investor_score = -15 + else: + investor_text = "외인·기관 중립" + else: + # 응답이 None이면 주말/공휴일 등 데이터 미존재 + investor_text = "주말/공휴일 (거래 없음)" + except Exception as e: + logger.debug(f"외인/기관 조회 실패({code}): {e}") + + # 종합 판정 점수 (0~100) + score = 50 # 기본값 + per_high = get_env_float("PER_HIGH_THRESHOLD", 30.0) + per_fair = get_env_float("PER_FAIR_THRESHOLD", 15.0) + per_low = get_env_float("PER_LOW_THRESHOLD", 10.0) + peg_great = get_env_float("PEG_GREAT_THRESHOLD", 1.0) + peg_good = get_env_float("PEG_GOOD_THRESHOLD", 1.5) + peg_bad = get_env_float("PEG_BAD_THRESHOLD", 2.0) + rsi_oversold = get_env_float("RSI_OVERSOLD_THRESHOLD", 30.0) + rsi_weak = get_env_float("RSI_WEAK_THRESHOLD", 40.0) + rsi_overbought = get_env_float("RSI_OVERBOUGHT_THRESHOLD", 70.0) + + rsi_comment = "적정" + + if per is not None: + if per < per_low: + score += 30 + elif per < per_fair: + score += 20 + elif per < per_high: + score += 10 + elif per >= per_high: + score -= 20 + if peg is not None: + if peg < peg_great: + score += 30 + elif peg < peg_good: + score += 15 + elif peg > peg_bad: + score -= 20 + if rsi is not None: + if rsi < rsi_oversold: + score += 20 + rsi_comment = "과매도" + elif rsi < rsi_weak: + score += 10 + rsi_comment = "약세" + elif rsi >= rsi_overbought: + score -= 20 + rsi_comment = "과매수" + elif rsi > 60.0: + rsi_comment = "과매수 근접" + + if ma20 is not None and ma60 is not None and ma20 > ma60: + score += 10 + score += investor_score + score = max(0, min(100, int(score))) + + # 판정 라벨 및 한 줄 설명 (기준값 전부 env) + v_full_score = get_env_float("LONG_VERDICT_FULL_SCORE", 75.0) + v_full_profit = get_env_float("LONG_VERDICT_FULL_PROFIT_PCT", 50.0) + v_partial_score = get_env_float("LONG_VERDICT_PARTIAL_SCORE", 60.0) + v_partial_profit = get_env_float("LONG_VERDICT_PARTIAL_PROFIT_PCT", 20.0) + v_stop_score = get_env_float("LONG_VERDICT_STOP_SCORE", 55.0) + v_stop_loss = get_env_float("LONG_VERDICT_STOP_LOSS_PCT", -10.0) + v_stop_mild = get_env_float("LONG_VERDICT_STOP_LOSS_MILD_PCT", -5.0) + v_earn_profit = get_env_float("LONG_VERDICT_EARN_PROFIT_MIN", 30.0) + v_earn_rsi = get_env_float("LONG_VERDICT_EARN_RSI_MIN", 65.0) + + if score >= v_full_score and profit_pct > v_full_profit: + verdict_label = "🎯 전량 익절" + verdict_reason = "고점 도달" + elif score >= v_partial_score + 10 and profit_pct > v_partial_profit: + verdict_label = "분할 익절" + verdict_reason = "수익 일부 실현" + elif score >= v_partial_score: + verdict_label = "분할 익절" + verdict_reason = "수익 일부 실현" + elif score >= v_stop_score and profit_pct < v_stop_loss: + verdict_label = "⚠️ 손절 고려 (확률 55%)" + verdict_reason = "하락세 + 점수 낮음. 손절 후 바닥에서 재진입 검토" + elif score <= v_stop_score and profit_pct < v_stop_mild: + verdict_label = "🚨 손절 검토 (확률 65%)" + verdict_reason = "손실 크고 하락 추세. 추가 손실 가능" + elif profit_pct > v_earn_profit and rsi is not None and rsi > v_earn_rsi: + verdict_label = "🎯 익절 권장" + verdict_reason = "큰 수익 + 하락 전환" + else: + verdict_label = "관망" + verdict_reason = "추가 관찰" + + return { + "code": code, + "name": name, + "current_price": current_price, + "day_change_pct": day_change_pct, + "avg_price": avg_price, + "qty": qty, + "profit_val": profit_val, + "profit_pct": profit_pct, + "per": per, + "peg": peg, + "rsi": rsi, + "rsi_comment": rsi_comment, + "volatility": volatility, + "ma_arrangement": ma_arrangement, + "investor_text": investor_text, + "score": score, + "verdict_label": verdict_label, + "verdict_reason": verdict_reason, + } + + def _minimal_overseas_analysis(self, code: str, item: Dict) -> Optional[Dict]: + """해외 종목: 현재가만 조회 후 최소 분석 (PER/RSI/외인 데이터 없음).""" + name = item.get("name", code) + avg_price = float(item.get("avg_price") or 0) + qty = int(item.get("qty") or 0) + is_holding = qty > 0 and avg_price > 0 + + try: + price_data = self.client.inquire_price(code) + except Exception as e: + logger.warning(f"해외 현재가 조회 실패({code}): {e}") + return {"code": code, "name": name, "error": "현재가 조회 실패"} + + if not price_data: + return {"code": code, "name": name, "error": "현재가 조회 실패"} + + try: + current_price = abs(float(price_data.get("stck_prpr") or 0)) + prdy_ctrt_raw = (price_data.get("prdy_ctrt") or "0").strip().replace("+", "").replace("%", "") + try: + day_change_pct = float(prdy_ctrt_raw) + except Exception: + day_change_pct = 0.0 + except Exception as e: + return {"code": code, "name": name, "error": "가격 파싱 실패"} + + if current_price <= 0: + return {"code": code, "name": name, "error": "현재가 없음"} + + profit_val = (current_price - avg_price) * qty if is_holding else 0.0 + profit_pct = (current_price - avg_price) / avg_price * 100 if is_holding else 0.0 + + if profit_pct > 50: + verdict_label, verdict_reason = "🎯 전량 익절", "고점 도달" + elif profit_pct > 20: + verdict_label, verdict_reason = "분할 익절", "수익 일부 실현" + elif profit_pct < -20: + verdict_label, verdict_reason = "🚨 손절 검토 (확률 65%)", "손실 크고 하락 추세. 추가 손실 가능" + elif profit_pct < -10: + verdict_label, verdict_reason = "⚠️ 손절 고려 (확률 55%)", "하락세 + 점수 낮음. 손절 후 바닥에서 재진입 검토" + elif profit_pct > 30: + verdict_label, verdict_reason = "🎯 익절 권장", "큰 수익 + 하락 전환" + else: + verdict_label, verdict_reason = "관망", "추가 관찰" + + return { + "code": code, + "name": name, + "current_price": current_price, + "day_change_pct": day_change_pct, + "avg_price": avg_price, + "qty": qty, + "profit_val": profit_val, + "profit_pct": profit_pct, + "per": None, + "peg": None, + "rsi": None, + "volatility": None, + "ma_arrangement": "데이터없음", + "investor_text": "데이터 없음 (해외종목 또는 미제공)", + "score": 50, + "verdict_label": verdict_label, + "verdict_reason": verdict_reason, + } + + def _format_one_stock_block( + self, + item: Dict, + analysis: Dict, + ai_text: str, + currency: str = "KRW", + ) -> str: + """ + 한 종목의 linux_bot 형식 블록 생성. + 💰 수익률 → 📊 외인/기관 → 📈 차트 → 💼 밸류 → 🎯 판정 → 🤖 AI 분석 + """ + name = analysis.get("name", item.get("name", "")) + code = analysis.get("code", item.get("code", "")) + + if analysis.get("error"): + return f"{name} ({code}): {analysis['error']}\n" + + is_krw = currency == "KRW" + unit = "원" if is_krw else " USD" + + profit_val = analysis.get("profit_val", 0) + profit_pct = analysis.get("profit_pct", 0) + qty = analysis.get("qty", 0) + avg_price = analysis.get("avg_price", 0) + current_price = analysis.get("current_price", 0) + day_change_pct = analysis.get("day_change_pct", 0) + + # 💰 수익률 (보유 시) or 현재가 (관심만) + if qty > 0 and avg_price > 0: + if is_krw: + line_profit = ( + f"💰 수익률: {profit_val:+,.0f}{unit} | " + f"보유 {profit_pct:+.1f}% ({profit_val:+,.0f}{unit}) | " + f"전일대비 {day_change_pct:+.2f}%" + ) + else: + line_profit = ( + f"💰 수익률: {profit_val:+.0f}{unit} | " + f"보유 {profit_pct:+.1f}% ({profit_val:+.0f}{unit}) | " + f"전일대비 {day_change_pct:+.2f}%" + ) + else: + if is_krw: + line_profit = f"💰 현재가: {current_price:,.0f}{unit} | 전일대비 {day_change_pct:+.2f}%" + else: + line_profit = f"💰 현재가: {current_price:.2f}{unit} | 전일대비 {day_change_pct:+.2f}%" + + # 📊 외인/기관 + line_inv = f"📊 외인/기관: {analysis.get('investor_text', '데이터 없음')}" + + # 📈 차트: RSI | 변동성 | 정배열/역배열 + rsi = analysis.get("rsi") + rsi_comment = analysis.get("rsi_comment", "적정") + vol = analysis.get("volatility") + arr = analysis.get("ma_arrangement", "") + + if rsi is not None: + rsi_str = f"RSI {rsi:.1f} ({rsi_comment})" + else: + rsi_str = "RSI -" + + vol_str = f"{vol:.2f}%" if vol is not None else "-" + line_chart = f"📈 차트: {rsi_str} | 변동성 {vol_str} | {arr}" + + # 💼 밸류: PER / PEG (없으면 줄 생략) + per = analysis.get("per") + peg = analysis.get("peg") + per_high = get_env_float("PER_HIGH_THRESHOLD", 30.0) + per_fair = get_env_float("PER_FAIR_THRESHOLD", 15.0) + peg_great = get_env_float("PEG_GREAT_THRESHOLD", 1.0) + line_value = None + if per is not None: + per_comment = "높음" if per > per_high else ("적정" if per > per_fair else "저평가") + line_value = f"💼 밸류: PER {per:.1f} ({per_comment})" + if peg is not None: + peg_comment = "저평가" if peg < peg_great else "적정" + line_value += f" | PEG {peg:.2f} ({peg_comment})" + + # 🎯 판정 + score = analysis.get("score", 0) + vlabel = analysis.get("verdict_label", "") + vreason = analysis.get("verdict_reason", "") + line_verdict = f"🎯 판정: {score}점 → {vlabel}\n └ {vreason}" + + # 🤖 AI 분석 + line_ai = "🤖 AI 분석\n" + (ai_text.strip() if ai_text else " (분석 없음)") + + parts = [f"**{name} ({code})**", line_profit, "", line_inv, ""] + parts.append(line_chart) + if line_value: + parts.append(line_value) + parts.extend([line_verdict, "", line_ai, ""]) + return "\n".join(parts) + + def _get_ai_4lines( + self, + name: str, + code: str, + analysis: Dict, + trade_summary: Optional[Dict], + news_summary: str, + ) -> str: + """ + Gemini로 종목별 장기 투자 관점 4줄 생성: + 📌 수치/흐름 / 📰 뉴스 관점 / 💡 판단 / ⚠️ 리스크 + """ + if not gemini_model: + return " (Gemini 미설정)" + + try: + rsi = analysis.get("rsi") + vol = analysis.get("volatility") + arr = analysis.get("ma_arrangement", "") + per = analysis.get("per") + peg = analysis.get("peg") + score = analysis.get("score") + verdict = analysis.get("verdict_label", "") + profit_pct = analysis.get("profit_pct", 0) + investor = analysis.get("investor_text", "") + + trade_str = "" + if trade_summary: + t = trade_summary + trade_str = ( + f"최근 매매: {t['total']}건, 승률 {t['wins']}/{t['total']}, " + f"평균 수익률 {t['avg_profit']:.1f}%, 주요 사유 {[r[0] for r in t['top_reasons']]}." + ) + + prompt = f"""다음 종목에 대해 장기 투자 관점으로 **4줄**만 작성하세요. 각 줄은 반드시 지정 접두사로 시작하세요. + +종목: {name} ({code}) +- RSI: {rsi} | 변동성: {vol}% | 추세: {arr} +- PER: {per} | PEG: {peg} | 판정: {score}점 → {verdict} +- 보유 수익률: {profit_pct:.1f}% | 외인/기관: {investor} +{trade_str} +뉴스/시장 요약: {news_summary[:500] if news_summary else '없음'} + +정확히 4줄, 아래 형식으로만 작성(한글): +📌 수치/흐름: (RSI·변동성·추세 한 문장) +📰 뉴스 관점: (호재/악재·수급 한 문장) +💡 판단: (매수/관망/익절/손절 중 하나 + 이유 한 문장) +⚠️ 리스크: (주요 리스크 한 문장) +""" + response = gemini_model.generate_content(prompt) + if response and response.text: + return " " + response.text.strip().replace("\n", "\n ") + except Exception as e: + logger.warning(f"AI 4줄 분석 실패({code}): {e}") + return " (AI 분석 생성 실패)" + + # ------------------------------------------------------------------ + # linux_bot 형식 장기 투자 체크 리포트 (핵심 메서드) + # ------------------------------------------------------------------ + def send_long_check_report(self, when_label: str = "정기") -> None: + """ + linux_bot 형식으로 전 종목 장기 투자 체크 리포트 생성 후 MM 전송. + 종목당 블록: 💰 수익률 → 📊 외인/기관 → 📈 차트 → 💼 밸류 → 🎯 판정 → 🤖 AI 4줄 + """ + items = self._get_watch_items() + max_ai = get_env_int("LONG_AI_MAX_STOCKS", 20) + ai_recent_trades = get_env_int("LONG_AI_RECENT_TRADES", 10) + + # 뉴스 요약 수집 (AI 프롬프트에 시장 맥락으로 활용) + news_summary = "" + try: + if self.news_analyzer and getattr(self.news_analyzer, "client", None): + news_list = self.news_analyzer.crawl_naver_finance_news(max_news=3) + if news_list: + analysis = self.news_analyzer.analyze_news_with_claude(news_list) + if analysis and isinstance(analysis, dict): + raw = analysis.get("summary") or analysis.get("market_outlook") or "" + news_summary = (raw[:800] if isinstance(raw, str) else str(raw)[:800]) + except Exception as e: + logger.debug(f"뉴스 요약 수집 실패(무시): {e}") + + blocks: List[str] = [] + for i, item in enumerate(items): + code = (item.get("code") or "").strip() + name = item.get("name", code) + if not code: + continue + + # 국내 vs 해외 분기 + if self._is_domestic_code(code): + analysis = self._full_stock_analysis(code, item) + currency = "KRW" + else: + analysis = self._minimal_overseas_analysis(code, item) + currency = "USD" + + if analysis is None: + blocks.append(f"{name} ({code}): 분석 실패\n") + continue + if analysis.get("error"): + blocks.append(f"{name} ({code}): {analysis['error']}\n") + continue + + # AI 4줄 분석 (상위 max_ai 종목만, Gemini 있을 때) + trade_summary = self._get_trade_history_summary(code, max_trades=ai_recent_trades) + if i < max_ai and gemini_model: + ai_text = self._get_ai_4lines(name, code, analysis, trade_summary, news_summary) + else: + ai_text = " (AI 분석 생략)" + + block = self._format_one_stock_block(item, analysis, ai_text, currency=currency) + blocks.append(block) + + # API Rate Limit 방지용 딜레이 (env 기반) + try: + if self.analysis_delay_max > 0: + time.sleep(random.uniform(self.analysis_delay_min, self.analysis_delay_max)) + except Exception: + pass + + title = f"📊 장기 투자 체크 (한투 KIS API + AI 분석)" + separator = "━" * 30 + msg = f"{title}\n{separator}\n\n" + f"\n{separator}\n\n".join(b.strip() for b in blocks if b.strip()) + try: + self.mm.send(self.mm_channel, msg) + logger.info(f"📊 롱봇V2 장기 투자 체크 리포트 전송 완료 ({len(blocks)}개 종목)") + except Exception as e: + logger.error(f"장기 투자 체크 리포트 전송 실패: {e}") + + # ------------------------------------------------------------------ + # 뉴스 분석/알림 + # ------------------------------------------------------------------ + def _should_run_news_now(self, now: Optional[dt] = None) -> bool: + if not self.news_enabled: + return False + now = now or dt.now() + h = now.hour + # 활동 시간대만 (예: 9~23시) + if self.news_active_start_hour <= self.news_active_end_hour: + return self.news_active_start_hour <= h < self.news_active_end_hour + # 야간 랩어라운드 형태 (예: 20~5시)도 지원 + return h >= self.news_active_start_hour or h < self.news_active_end_hour + + def _collect_watchlist_names(self) -> Dict[str, str]: + m: Dict[str, str] = {} + for it in self._get_watch_items(): + code = it["code"] + name = it.get("name", code) + if code: + m[code] = name + return m + + def run_news_cycle_once(self) -> None: + """뉴스 한 사이클 (크롤링 → Claude 분석 → watchlist 관련 이슈만 필터링 → MM 전송).""" + if not self._should_run_news_now(): + return + if not self.news_analyzer or not getattr(self.news_analyzer, "client", None): + logger.debug("뉴스 분석 비활성 (API 키/클라이언트 없음)") + return + + try: + logger.info("📰 [롱봇V2] 뉴스 분석 사이클 시작") + news_list = self.news_analyzer.crawl_naver_finance_news(max_news=self.news_max_count) + if not news_list: + logger.info("📰 [롱봇V2] 크롤링된 뉴스 없음") + return + + analysis = self.news_analyzer.analyze_news_with_claude(news_list) + if not analysis: + logger.info("📰 [롱봇V2] 뉴스 AI 분석 결과 없음") + return + + watch_map = self._collect_watchlist_names() + watch_codes = set(watch_map.keys()) + + related: List[Dict] = [] + for stock in analysis.get("recommended_stocks", []): + code = (stock.get("code") or "").strip() + if code and code in watch_codes: + related.append( + { + "code": code, + "name": watch_map.get(code, stock.get("name", "")), + "reason": stock.get("reason", ""), + } + ) + + mm_msg = self.news_analyzer.format_analysis_for_mattermost(analysis, news_list) + if not mm_msg: + logger.info("📰 [롱봇V2] 포맷된 뉴스 메시지 없음") + return + + if related: + extra_lines = ["\n**🎯 위시리스트 관련 종목**"] + for r in related: + extra_lines.append(f"- `{r['code']}` {r['name']}: {r['reason']}") + mm_msg += "\n" + "\n".join(extra_lines) + else: + mm_msg += "\n_현재 위시리스트와 직접 매칭된 종목은 없음 (전체 시장 참고용)_" + + self.mm.send(self.mm_channel, mm_msg) + logger.info(f"📰 [롱봇V2] 뉴스 분석 알림 전송 완료 (관련 종목 {len(related)}개)") + except Exception as e: + logger.error(f"❌ 롱봇V2 뉴스 분석 사이클 실패: {e}") + + # ------------------------------------------------------------------ + # 메인 루프 + # ------------------------------------------------------------------ + async def _report_scheduler_loop(self) -> None: + """장 시작/마감 시각에 맞춰 하루 2회 장기 투자 체크 리포트 전송.""" + logger.info( + f"📅 롱봇V2 리포트 스케줄러 시작 (오전 {self.report_am_hour:02d}:{self.report_am_min:02d}, " + f"마감 {self.report_pm_hour:02d}:{self.report_pm_min:02d})" + ) + # 시작 직후 잔고 API 연타 방지 (숏봇·다른 프로세스와 같은 앱키 공유 시 EGW00201 완화) + await asyncio.sleep(5) + last_date = "" + while True: + try: + now = dt.now() + today = now.strftime("%Y-%m-%d") + if today != last_date: + last_date = today + self.morning_report_sent = False + self.closing_report_sent = False + self.today_date = today + + if ( + not self.morning_report_sent + and now.hour == self.report_am_hour + and now.minute == self.report_am_min + ): + self.send_long_check_report("장 시작") + self.morning_report_sent = True + + if ( + not self.closing_report_sent + and now.hour == self.report_pm_hour + and now.minute == self.report_pm_min + ): + self.send_long_check_report("장 마감") + self.closing_report_sent = True + + except Exception as e: + logger.error(f"리포트 스케줄러 루프 에러: {e}") + + await asyncio.sleep(30) + + async def _news_loop(self) -> None: + """뉴스 감시 루프 (기본 1시간 간격, env로 조절).""" + if not self.news_enabled: + logger.info("📰 롱봇V2 뉴스 감시 비활성화 (LONG_NEWS_ENABLED=false)") + return + + interval = self.news_interval_min if self.news_interval_min > 0 else 60 + logger.info(f"📰 롱봇V2 뉴스 감시 시작 (interval={interval}분)") + + while True: + try: + if self._should_run_news_now(): + self.run_news_cycle_once() + else: + logger.debug("📰 롱봇V2 뉴스 감시: 비활성 시간대 (sleep)") + except Exception as e: + logger.error(f"뉴스 감시 루프 에러: {e}") + + await asyncio.sleep(interval * 60) + + def run(self) -> None: + """동기 진입점: asyncio 루프에서 리포트/뉴스 태스크를 함께 실행.""" + async def _main(): + tasks = [ + asyncio.create_task(self._report_scheduler_loop()), + asyncio.create_task(self._news_loop()), + ] + await asyncio.gather(*tasks) + + try: + asyncio.run(_main()) + except KeyboardInterrupt: + logger.info("💤 롱봇V2 종료 (KeyboardInterrupt)") + except Exception as e: + logger.error(f"💥 롱봇V2 메인 루프 에러: {e}") + + +def main(): + logger.info("🚀 KIS Long Watch Bot Ver2 시작") + bot = LongWatchBotV2() + bot.run() + + +if __name__ == "__main__": + main() diff --git a/kis_scalping_ver1.py b/kis_scalping_ver1.py new file mode 100644 index 0000000..fc94415 --- /dev/null +++ b/kis_scalping_ver1.py @@ -0,0 +1,1658 @@ +""" +kis_scalping_ver1.py — KIS WebSocket 기반 초단타(스캘핑) 봇 +════════════════════════════════════════════════════════════ +전략: RSI 과매도(= expired_dt - margin: + logger.info("한투 토큰 만료 임박/만료 → 재발급") + return None + logger.info("한투 토큰 캐시 재사용 (만료: %s)", expired_dt.strftime("%H:%M:%S")) + return {"access_token": token, "expires_at": expired_dt.isoformat()} + except Exception: + return None + + +def _save_kis_token_cache(mock, token, expired_str): + path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL + try: + with open(path, "w", encoding="utf-8") as f: + json.dump({"mock": mock, "access_token": token, + "access_token_token_expired": expired_str}, f) + except Exception as e: + logger.warning("한투 토큰 캐시 저장 실패: %s", e) + + +# ══════════════════════════════════════════════════════════════════════ +# KIS REST 클라이언트 (ver2 에서 직접 이식 — 구조 동일) +# ══════════════════════════════════════════════════════════════════════ +class KISClient: + """한국투자증권 REST API 클라이언트. ver2 KISClient 와 동일 구조.""" + + def __init__(self, app_key, app_secret, account_no, account_code, mock=True): + self.app_key = app_key + self.app_secret = app_secret + self.account_no = account_no + self.account_code = account_code + self.mock = mock + self.base_url = ( + "https://openapivts.koreainvestment.com:29443" + if mock else + "https://openapi.koreainvestment.com:9443" + ) + self._token: Optional[str] = None + self._token_lock = __import__("threading").Lock() + # API 호출 간 최소 간격 (429 방지) + self._last_call_ts: float = 0.0 + self._min_interval: float = 0.22 # 초 (초당 ~4건 안전 마진) + # 마지막 매도 오류 캐시 (execute_sell 에서 사용) + self._last_sell_msg_cd: Optional[str] = None + self._last_sell_msg1: Optional[str] = None + self._init_token() + + def _init_token(self): + cache = _load_kis_token_cache(self.mock) + if cache: + self._token = cache["access_token"] + else: + try: + from kis_token_manager import ensure_token + if ensure_token(self.mock): + cache = _load_kis_token_cache(self.mock) + self._token = cache["access_token"] if cache else None + except Exception as e: + logger.warning("kis_token_manager 발급 실패: %s", e) + self._token = None + + def _issue_token(self) -> Optional[str]: + try: + r = requests.post( + f"{self.base_url}/oauth2/tokenP", + json={"grant_type": "client_credentials", + "appkey": self.app_key, "appsecret": self.app_secret}, + timeout=10, + ) + j = r.json() + token = j.get("access_token") + if token: + _save_kis_token_cache(self.mock, token, + j.get("access_token_token_expired", "")) + logger.info("✅ 한투 토큰 발급 완료") + return token + except Exception as e: + logger.error("❌ 한투 토큰 발급 실패: %s", e) + return None + + def _throttle(self): + """ + API 호출 전 처리: + 1. 토큰 만료 10분 전 선제 갱신 (KisTokenManager) + 2. 최소 호출 간격 대기 (429 방지) + """ + # 토큰 갱신 체크 (유효하면 메모리 반환, 만료 임박 시만 재발급) + try: + from kis_token_manager import KisTokenManager + fresh = KisTokenManager.instance(is_mock=self.mock).get_token() + if fresh: + self._token = fresh + except Exception: + pass + elapsed = time.time() - self._last_call_ts + if elapsed < self._min_interval: + time.sleep(self._min_interval - elapsed) + self._last_call_ts = time.time() + + def _get(self, path, tr_id, params, retry=5): + """GET 요청 + EGW00201 자동 재시도.""" + self._throttle() + headers = { + "authorization": f"Bearer {self._token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + "custtype": "P", + } + url = self.base_url + path + for attempt in range(1, retry + 1): + try: + r = requests.get(url, headers=headers, params=params, timeout=10) + if r.status_code == 200: + j = r.json() + if j.get("msg_cd") == "EGW00201": + wait = 1.5 + logger.warning( + "⏳ API 초당거래 초과 (EGW00201) GET %s -> %.1f초 대기 후 재시도 (%d/%d) msg1=%s", + path, wait, attempt, retry, j.get("msg1", ""), + ) + time.sleep(wait) + continue + return r + except requests.exceptions.Timeout: + logger.warning("⏰ GET %s 타임아웃 (%d/%d)", path, attempt, retry) + time.sleep(1.0) + return requests.Response() + + def _post(self, path, tr_id, body, retry=3): + """POST 요청 + EGW00201 자동 재시도.""" + self._throttle() + headers = { + "authorization": f"Bearer {self._token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + "content-type": "application/json; charset=utf-8", + "custtype": "P", + } + url = self.base_url + path + for attempt in range(1, retry + 1): + try: + r = requests.post(url, headers=headers, json=body, timeout=10) + if r.status_code == 200: + j = r.json() + if j.get("msg_cd") == "EGW00201": + wait = 1.5 + logger.warning( + "⏳ API 초당거래 초과 (EGW00201) POST %s -> %.1f초 대기 (%d/%d)", + path, wait, attempt, retry, + ) + time.sleep(wait) + continue + return r + except requests.exceptions.Timeout: + logger.warning("⏰ POST %s 타임아웃 (%d/%d)", path, attempt, retry) + time.sleep(1.0) + return requests.Response() + + def inquire_price(self, code: str) -> Optional[dict]: + """현재가 조회 [v1_국내주식-007]""" + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-price", + "FHKST01010100", + {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code}, + ) + if r.status_code != 200: + return None + j = r.json() + return j.get("output") if j.get("rt_cd") == "0" else None + + def get_minute_chart(self, code: str, period: str = "1", limit: int = 100) -> pd.DataFrame: + """ + 분봉 차트 조회 [v1_국내주식-017] — 재접속 갭 보정용. + 스캘핑봇은 이 함수를 정상 운영 중에는 호출하지 않음. + """ + path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + tr_id = "FHKST03010200" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=1) + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_HOUR_1": start_dt.strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": end_dt.strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + "FID_ETC_CLS_CODE": "", + } + r = self._get(path, tr_id, params) + if r.status_code != 200: + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + return pd.DataFrame() + data = j.get("output2", []) + if not data: + return pd.DataFrame() + rows = [] + for item in data: + try: + date_str = str(item.get("stck_bsop_date", "") or "") + time_str = str(item.get("stck_cntg_hour", "") or "000000") + rows.append({ + "time": date_str + time_str[:4], # YYYYMMDDHHMM + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except Exception: + continue + if not rows: + return pd.DataFrame() + df = pd.DataFrame(rows) + df = df.sort_values("time").reset_index(drop=True) + return df.tail(limit) + except Exception as e: + logger.error("분봉 조회 실패(%s): %s", code, e) + return pd.DataFrame() + + def get_investor_trend(self, code: str, days: int = 3) -> Optional[dict]: + """투자자 동향 조회 (수급 확인용)""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-investor", + "FHKST01010900", + {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code, + "FID_INPUT_DATE_1": (dt.now() - datetime.timedelta(days=days)).strftime("%Y%m%d"), + "FID_INPUT_DATE_2": dt.now().strftime("%Y%m%d"), + "FID_PERIOD_DIV_CODE": "D"}, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + output = j.get("output", []) + if not output: + return None + foreign_net = sum(int(str(o.get("frgn_ntby_qty", 0)).replace(",", "")) + for o in output if o) + org_net = sum(int(str(o.get("orgn_ntby_qty", 0)).replace(",", "")) + for o in output if o) + return {"foreign_net_buy": foreign_net, "org_net_buy": org_net} + except Exception as e: + logger.debug("투자자 동향 조회 실패(%s): %s", code, e) + return None + + def get_account_balance(self) -> Optional[dict]: + """계좌 잔고 조회""" + tr_id = "VTTC8434R" if self.mock else "TTTC8434R" + try: + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance", + tr_id, + {"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, + "AFHR_FLPR_YN": "N", "OFL_YN": "N", "INQR_DVSN": "01", + "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "00", + "CTX_AREA_FK100": "", "CTX_AREA_NK100": ""}, + ) + if r.status_code != 200: + return None + j = r.json() + return j if j.get("rt_cd") == "0" else None + except Exception as e: + logger.error("계좌 잔고 조회 실패: %s", e) + return None + + def buy_order(self, code: str, qty: int, price: int = 0, + order_type: str = "01") -> bool: + """매수 주문 (01=시장가, 00=지정가)""" + tr_id = "VTTC0802U" if self.mock else "TTTC0802U" + path = "/uapi/domestic-stock/v1/trading/order-cash" + try: + body = { + "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, + "PDNO": code, "ORD_DVSN": order_type, + "ORD_QTY": str(qty), "ORD_UNPR": str(price), + } + r = self._post(path, tr_id, body) + if r.status_code != 200: + return False + j = r.json() + if j.get("rt_cd") == "0": + logger.info("✅ 매수주문 성공: %s %d주 @ %d원", code, qty, price) + return True + logger.error("❌ 매수주문 실패: %s | msg=%s", code, j.get("msg1", "")) + return False + except Exception as e: + logger.error("매수 주문 예외(%s): %s", code, e) + return False + + def sell_order(self, code: str, qty: int, price: int = 0, + order_type: str = "01") -> bool: + """매도 주문 (01=시장가, 00=지정가)""" + tr_id = "VTTC0801U" if self.mock else "TTTC0801U" + path = "/uapi/domestic-stock/v1/trading/order-cash" + try: + body = { + "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, + "PDNO": code, "ORD_DVSN": order_type, + "ORD_QTY": str(qty), "ORD_UNPR": str(price), + } + r = self._post(path, tr_id, body) + if r.status_code != 200: + return False + j = r.json() + if j.get("rt_cd") == "0": + return True + self._last_sell_msg_cd = j.get("msg_cd", "") + self._last_sell_msg1 = j.get("msg1", "") + logger.error("❌ 매도주문 실패: %s | msg=%s", code, self._last_sell_msg1) + return False + except Exception as e: + self._last_sell_msg_cd = None + self._last_sell_msg1 = None + logger.error("매도 주문 예외(%s): %s", code, e) + return False + + def sell_market_order(self, code: str, qty: int) -> bool: + return self.sell_order(code, qty, price=0, order_type="01") + + +# ══════════════════════════════════════════════════════════════════════ +# 알림 헬퍼 +# ══════════════════════════════════════════════════════════════════════ +def _load_mm_channel_id(channel_alias: str) -> Optional[str]: + try: + if MM_CONFIG_FILE.exists(): + with open(MM_CONFIG_FILE, "r", encoding="utf-8") as f: + cfg = json.load(f) + return cfg.get("channels", {}).get(channel_alias) + except Exception: + pass + return None + + +def msg_mm(text: str, channel_alias: str = None) -> None: + """Mattermost 알림 전송.""" + alias = channel_alias or MM_CHANNEL_SCALP + cid = _load_mm_channel_id(alias) + if not cid or not MM_BOT_TOKEN: + logger.debug("[MM 알림 스킵] channel=%s cid=%s", alias, cid) + return + try: + requests.post( + f"{MM_SERVER_URL}/api/v4/posts", + headers={"Authorization": f"Bearer {MM_BOT_TOKEN}", + "Content-Type": "application/json"}, + json={"channel_id": cid, "message": text}, + timeout=5, + ) + except Exception as e: + logger.debug("MM 알림 실패: %s", e) + + +# ══════════════════════════════════════════════════════════════════════ +# 스캘핑 봇 메인 클래스 +# ══════════════════════════════════════════════════════════════════════ +class ScalpingBotV1: + """ + WebSocket 봉 집계 기반 초단타 스캘핑 봇. + + 매수 조건 (AND 조건) + ───────────────────── + 1. 장 시작 후 SCALP_MARKET_OPEN_WAIT_MIN 분 경과 + 2. 당일 낙폭 >= MIN_DROP_RATE (ver2 동일) + 3. 저점 회복률 >= MIN_RECOVERY_RATIO_SHORT (ver2 동일) + 4. RSI(3) <= SCALP_RSI_OVERSOLD (과매도 구간) + 5. 이전 봉 close < open (하락) → 현재 봉 close > open (반등 시작) + 6. MA5 위에 있거나, 현재봉 거래량 >= 평균 거래량 × VOLUME_AVG_MULTIPLIER + 7. RSI(3) >= SCALP_RSI_OVERBOUGHT 시 진입 금지 (고점 추격 방지) + + 매도 조건 (ver2 check_sell_signals 와 동일) + ──────────────────────────────────────────── + - 손절: 현재가 <= stop_price + - 익절: 현재가 >= target_price + - 숄더컷, 퀵프로핏, ATR 트레일링 스탑 + """ + + def __init__(self): + # ── KIS 인증 정보 (DB 에서 로드) ───────────────────────── + is_mock = get_env_bool("KIS_MOCK", True) + if is_mock: + app_key = get_env_from_db("KIS_APP_KEY_MOCK", "") + app_secret = get_env_from_db("KIS_APP_SECRET_MOCK", "") + account_no = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "") + account_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "01") + else: + app_key = get_env_from_db("KIS_APP_KEY_REAL", "") + app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "") + account_no = get_env_from_db("KIS_ACCOUNT_NO_REAL", "") + account_code = get_env_from_db("KIS_ACCOUNT_CODE_REAL", "01") + + self.client = KISClient(app_key, app_secret, account_no, account_code, mock=is_mock) + self.db = db + self.mm_channel = MM_CHANNEL_SCALP + + # ── 보유 종목 & 상태 ───────────────────────────────────── + self.holdings: Dict[str, dict] = {} + self.untradable_skip_set: set = set() + self.recently_sold: dict = {} + self._sell_backoff: dict = {} + self.today_date = dt.now().strftime("%Y-%m-%d") + # 갭보정 실패 재시도 대기열: 신규 구독 시 갭보정이 실패한 종목 코드 집합 + # check_buy_signal_scalp에서 봉부족 감지 시 자동으로 백그라운드 재갭보정 트리거 + self._gap_retry_queue: set = set() + # 봉부족 종목별 마지막 재갭보정 시각 (같은 종목 30초 내 중복 재시도 방지) + self._gap_retry_ts: dict = {} + + # ── 자산 추적 ───────────────────────────────────────────── + self.current_cash = 0.0 + self.current_total_asset = 0.0 + self.start_day_asset = 0.0 + + # ── 리포트 플래그 ───────────────────────────────────────── + self.morning_report_sent = False + self.closing_report_sent = False + + # ── 설정 로드 ───────────────────────────────────────────── + self.reload_config() + + # ── DB 에서 활성 포지션 복구 (SCALP_ 전략만) ────────────── + active_trades = self.db.get_active_trades(strategy_prefix="SCALP") + for code, trade in active_trades.items(): + self.holdings[code] = { + "buy_price": trade.get("avg_buy_price", 0), + "qty": trade.get("current_qty", 0), + "stop_price": trade.get("stop_price", 0), + "target_price": trade.get("target_price", 0), + "max_price": trade.get("max_price", 0), + "atr_entry": trade.get("atr_entry", 0), + "buy_time": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), + "name": trade.get("name", code), + "size_class": trade.get("size_class", ""), + } + + # ── WebSocket + CandleAggregator 초기화 ────────────────── + self.ws_cache: Optional[KISWebSocketPriceCache] = None + self.candle_agg: Optional[CandleAggregator] = None + self._init_websocket() + + # ── ML / RiskManager (선택적) ───────────────────────────── + self.ml_predictor = None + if ML_AVAILABLE and get_env_bool("USE_ML_SIGNAL", False): + try: + self.ml_predictor = MLPredictor() + except Exception as e: + logger.warning("ML 초기화 실패: %s", e) + + self.risk_manager = None + if RISK_MANAGER_AVAILABLE: + try: + self.risk_manager = RiskManager( + risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.015), + max_position_pct = get_env_float("MAX_POSITION_PCT", 0.03), + min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 20000), + use_kelly = get_env_bool("USE_KELLY", False), + kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25), + slot_base_amount_cap= get_env_int("SLOT_BASE_AMOUNT_CAP", 0), + # ── 무조건 깔고 가는 MAX_LOSS 기반 투자 상한 ───────── + max_loss_per_trade_krw=get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000), + stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.03), + # ── 사이즈 클래스별 비율 (DB에서 주입) ─────────────── + size_small_ratio = get_env_float("SIZE_CLASS_SMALL_RATIO", 0.70), + size_mid_ratio = get_env_float("SIZE_CLASS_MID_RATIO", 0.85), + ) + except Exception as e: + logger.warning("RiskManager 초기화 실패: %s", e) + + # ── 백그라운드 태스크 핸들 ──────────────────────────────── + self._report_task = None + + logger.info("🚀 ScalpingBotV1 초기화 완료 (mock=%s, holdings=%d)", + is_mock, len(self.holdings)) + + # ------------------------------------------------------------------ + # WebSocket + CandleAggregator 초기화 + # ------------------------------------------------------------------ + + def _init_websocket(self): + """WebSocket 시작 → CandleAggregator 연결 → 종목 구독 → 갭 보정.""" + if not _KIS_WS_AVAILABLE: + logger.warning("⚠️ kis_ws 모듈 없음 → WebSocket 비활성") + return + + try: + # [수정] 웹소켓은 데이터 수신용이므로 무조건 실전(Real) 서버를 타도록 하이브리드 구성 + ws_app_key = get_env_from_db("KIS_APP_KEY_REAL", "") + ws_app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "") + + self.ws_cache = KISWebSocketPriceCache( + app_key = ws_app_key if ws_app_key else self.client.app_key, + app_secret = ws_app_secret if ws_app_secret else self.client.app_secret, + is_mock = False, # 실전 서버 강제 접속 + ) + + # CandleAggregator 생성 후 WS 에 연결 + # 1분봉(주전략) + 3분봉 + 15분봉 + 60분봉 — 나중에 다른 전략 필터로 활용 가능 + tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1) + self.candle_agg = CandleAggregator(db=self.db, timeframes=[tf, 3, 15, 60]) + self.ws_cache.attach_candle_aggregator(self.candle_agg) + + ws_ok = self.ws_cache.start() + if not ws_ok: + logger.warning("⚠️ WebSocket 시작 실패 → 스캘핑봇은 WS 없이 동작 불가") + self.ws_cache = None + self.candle_agg = None + return + + # 기존 보유 종목 구독 + for code in list(self.holdings.keys()): + self.ws_cache.subscribe(code) + + # 키움봇 후보 종목도 미리 구독 + candidates = self.db.get_target_candidates() + for c in candidates: + code = c.get("code") or c.get("stk_cd", "") + if code: + self.ws_cache.subscribe(code) + + # ── 영구 구독 ETF: 시장 방향 필터용 (유니버스 변경과 무관하게 항상 유지) ── + # KOSPI(069500), KOSDAQ(229200) 지수 ETF의 60분봉 RSI → 상승장/하락장 판단 + perm_raw = get_env_from_db("PERMANENT_WS_CODES", "069500,229200") + self._permanent_ws_codes: set = { + c.strip() for c in str(perm_raw).split(",") if c.strip() + } + for code in sorted(self._permanent_ws_codes): + self.ws_cache.subscribe(code) + logger.info("📡 [영구구독] %s (시장방향 ETF)", code) + + logger.info("✅ WebSocket + CandleAggregator 활성 (%d종목 구독)", + len(self.ws_cache._subscribed)) + + # WS 연결 성공 시마다 갭보정 자동 실행 등록 + # (장 시간 첫 연결 시 REST 분봉 로드 → 봉부족 해소) + # 새벽 자동재연결 시에는 kis_ws 내부에서 is_market_hours() 체크 후 스킵 + self.ws_cache.set_on_connected_callback(self._fill_all_gaps) + + # 시작 시 즉시 한 번 실행 (장 중 재시작 대비) + self._fill_all_gaps() + + except Exception as e: + logger.warning("⚠️ WebSocket 초기화 예외: %s", e) + self.ws_cache = None + self.candle_agg = None + + def _fill_all_gaps(self): + """ + 봇 시작 시 또는 WS 재접속 시 모든 구독 종목의 봉 갭을 REST 로 보정. + RAM 버퍼(_confirmed)에 즉시 적재 → 다음 매수 체크에 즉시 반영. + DB는 백그라운드 큐로 비동기 저장. + + ▶ 키움 우선 전략: + - 키움 ka10080 은 1회 호출에 최대 900봉(≈6개월치) 제공 → 장 초반에도 즉시 봉 확보 가능 + - KIS get_minute_chart 는 당일봉만 제공 → 장 시작 직후 봉 부족 → 키움 우선 + - 키움 키 없으면 KIS fallback (1분/3분봉만, 15/60분봉은 KIS 지원 안 함) + """ + if not self.candle_agg or not self.ws_cache: + return + # ── 중복 실행 방지 ───────────────────────────────────────────── + if getattr(self, '_gap_filling', False): + logger.debug("갭보정 이미 진행 중 → 스킵") + return + self._gap_filling = True + try: + main_tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1) + limit = get_env_int("SCALP_GAP_FILL_LIMIT", 120) + with self.ws_cache._sub_lock: + codes = set(self.ws_cache._subscribed) + + # ── 키움 크레덴셜 조회 ──────────────────────────────────── + kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db) + use_kiwoom = bool(kw_key and kw_secret) + logger.info( + "🔧 [갭보정] %d종목 분봉 로드 시작 (tfs=%s, limit=%d, kiwoom=%s)", + len(codes), self.candle_agg.timeframes, limit, "✅" if use_kiwoom else "❌→KIS fallback", + ) + + for code in sorted(codes): + for tf in self.candle_agg.timeframes: + df = None + # 키움 우선: 어제 봉 포함, 장 초반에도 봉 바로 확보 + # ※ 토큰은 _get_kiwoom_token_cached()가 23시간 캐시 → au10001 한도 방지 + if use_kiwoom: + try: + df = get_kiwoom_candles_df( + code, tf, kw_key, kw_secret, + is_mock=kw_mock, n=limit, + ) + except Exception as e: + logger.debug("키움 갭보정 실패 (%s %dM): %s", code, tf, e) + + # KIS fallback: 당일봉만 → 1분/3분봉에만 유효 + if (df is None or df.empty) and tf <= 3: + try: + df = self.client.get_minute_chart( + code, period=str(tf), limit=limit + ) + except Exception as e: + logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e) + + if df is not None and not df.empty: + self.candle_agg.fill_gap_from_rest(code, tf, df) + # 같은 종목 내 timeframe 전환: 짧은 딜레이 (차트 API 레이트리밋) + time.sleep(random.uniform(0.2, 0.4)) + # 종목 간 딜레이: 조금 더 길게 + time.sleep(random.uniform(0.3, 0.6)) + finally: + self._gap_filling = False + + # ------------------------------------------------------------------ + # 설정 리로드 + # ------------------------------------------------------------------ + + def reload_config(self): + """DB 설정 실시간 반영 (메인 루프마다 호출).""" + # ── 스캘핑 전용 손익절: 꼬리잡기(STOP_LOSS_PCT/TAKE_PROFIT_PCT)와 분리 ── + # SCALP_STOP_LOSS_PCT : 스캘핑 손절 % (양수, 기본 1.5%) + # 1분봉 초단타에서 -4% 손절은 너무 넓어 자금이 묶임 → 1.5%로 타이트하게 설정 + # SCALP_TAKE_PROFIT_PCT: 스캘핑 익절 % (양수, 기본 1.5%) + # +5% 익절은 1분봉에서 거의 도달 불가 → 1.5%로 줄여 회전율 극대화 + scalp_sl = get_env_float("SCALP_STOP_LOSS_PCT", 0.015) + self.scalp_stop_loss_pct = -abs(scalp_sl) # 음수로 통일 (ex: -0.015) + self.scalp_take_profit_pct = get_env_float("SCALP_TAKE_PROFIT_PCT", 0.015) + + # 스캘핑 낙폭 기준: 꼬리잡기 MIN_DROP_RATE와 독립 + # MIN_DROP_RATE(3%) 그대로 쓰면 1분봉 소형주에선 걸러지는 종목이 너무 많아짐 + # SCALP_MIN_DROP_RATE 기본 1.5%로 완화 → 타점 빈도 증가 + self.scalp_min_drop_rate = get_env_float("SCALP_MIN_DROP_RATE", 0.015) + + # 글로벌 손절/익절 (호환 유지 - check_sell_signals 등에서 참조) + self.stop_loss_pct = self.scalp_stop_loss_pct + self.take_profit_pct = self.scalp_take_profit_pct + self.max_stocks = get_env_int("MAX_STOCKS", 3) + self.min_drop_rate = self.scalp_min_drop_rate # 하위호환 + # 일일 회복률: 스캘핑에선 사용하지 않음 (RSI 과매도와 모순). 설정만 유지. + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5) + + # 스캘핑 전용 + # RSI 과매도: 이 값 이하 봉이 나오면 되돌림 후보 (기본 25) + self.rsi_oversold = get_env_float("SCALP_RSI_OVERSOLD", 25.0) + # RSI 과매수: 이 값 이상이면 고점 추격 방지 진입 금지 (기본 75) + self.rsi_overbought = get_env_float("SCALP_RSI_OVERBOUGHT", 75.0) + # 봉 단위 (분) + self.candle_tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1) + # 장 시작 후 대기 (분) + self.open_wait_min = get_env_int("SCALP_MARKET_OPEN_WAIT_MIN", 5) + + # 포지션 크기 + self.slot_money = get_env_float("SLOT_MONEY_DEFAULT", 300000) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + + # ATR 매도 배수 + self.atr_up_mult = get_env_float("SCALP_ATR_UP_MULT", 1.5) + self.atr_down_mult = get_env_float("SCALP_ATR_DOWN_MULT", 0.8) + + # 피뢰침/급등 필터 + self.high_chase_thr = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + self.max_daily_chg = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) + + # 거래량 배율 필터 + self.vol_multiplier = get_env_float("VOLUME_AVG_MULTIPLIER", 1.5) + + # ML + self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False) + self.ml_min_prob = get_env_float("ML_MIN_PROBABILITY", 0.55) + + # 최소 가격 + self.min_price = get_env_float("MIN_PRICE_TAIL", 1000.0) + + # ------------------------------------------------------------------ + # 장 상태 체크 + # ------------------------------------------------------------------ + + def check_market_status(self) -> bool: + """장 중 여부 + 장 시작 후 워밍업 대기 확인.""" + now = dt.now() + h, m = now.hour, now.minute + + # 장외 시간 + if not ((9 <= h < 15) or (h == 15 and m <= 30)): + return False + if get_env_bool("FORCE_MARKET_OPEN", False): + return True + + # 장 시작(9:00) 후 open_wait_min 분 대기 + market_start = now.replace(hour=9, minute=0, second=0, microsecond=0) + elapsed_min = (now - market_start).total_seconds() / 60 + if elapsed_min < self.open_wait_min: + logger.info("⏳ 장 시작 후 %.0f분 경과 (워밍업 대기: %d분)", + elapsed_min, self.open_wait_min) + return False + return True + + # ------------------------------------------------------------------ + # 구독 관리 (새 후보 추가/구독 종목 정리) + # ------------------------------------------------------------------ + + def _sync_subscriptions(self, candidates: list): + """ + target_candidates DB 목록과 WS 구독 목록을 동기화. + - 새 종목 → subscribe + 갭 보정 + - 유니버스에서 빠진 종목(보유 중 아닌 것) → unsubscribe + RAM 정리 + ※ 영구 구독 ETF(_permanent_ws_codes)는 절대 해제하지 않음 (시장 방향 필터용) + """ + if not self.ws_cache: + return + new_codes = {c.get("code") or c.get("stk_cd", "") for c in candidates if c} + new_codes.discard("") + # 현재 보유 종목은 매도 완료 전까지 반드시 유지 + new_codes |= set(self.holdings.keys()) + # 영구 구독 ETF는 유니버스와 무관하게 항상 유지 + new_codes |= getattr(self, '_permanent_ws_codes', set()) + + with self.ws_cache._sub_lock: + current_subs = set(self.ws_cache._subscribed) + + # ── 구독 해제: 유니버스에서 빠진 종목 ───────────────────────── + # 보유 중 종목은 매도 감시를 위해 구독 유지 + for code in sorted(current_subs - new_codes): + self.ws_cache.unsubscribe(code) + if self.candle_agg: + self.candle_agg.remove_code(code) + + # ── 신규 구독: 유니버스에 새로 들어온 종목 ───────────────────── + kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db) + use_kiwoom = bool(kw_key and kw_secret) + for code in sorted(new_codes - current_subs): + self.ws_cache.subscribe(code) + # 구독 즉시 갭 보정 (봉 버퍼 없는 신규 종목) — 키움 우선 + if not self.candle_agg: + continue + lim = get_env_int("SCALP_GAP_FILL_LIMIT", 120) + ok = False + for tf in self.candle_agg.timeframes: + df = None + if use_kiwoom: + try: + df = get_kiwoom_candles_df( + code, tf, kw_key, kw_secret, + is_mock=kw_mock, n=lim, + ) + except Exception as e: + logger.debug("키움 신규갭보정 실패 (%s %dM): %s", code, tf, e) + if (df is None or df.empty) and tf <= 3: + try: + df = self.client.get_minute_chart( + code, period=str(tf), limit=lim + ) + except Exception as e: + logger.debug("KIS 신규갭보정 실패 (%s %dM): %s", code, tf, e) + if df is not None and not df.empty: + self.candle_agg.fill_gap_from_rest(code, tf, df) + ok = True + # tf 간 딜레이 (차트 API, 토큰은 캐시 재사용) + time.sleep(random.uniform(0.2, 0.4)) + if ok: + logger.info("🔧 [신규갭보정] %s: 모든 tf 로드 완료", code) + else: + logger.warning("⚠️ [신규갭보정실패] %s: 데이터 없음 → 재시도 대기열 등록", code) + self._gap_retry_queue.add(code) + + # ------------------------------------------------------------------ + # 매수 신호: RSI 과매도 되돌림 + # ------------------------------------------------------------------ + + def check_buy_signal_scalp(self, code: str, name: str) -> Optional[dict]: + """ + [스캘핑 진입 조건 — 1분봉 초단타 특화] + 1. RAM 버퍼에서 최근 확정 봉 조회 (REST 없음) + 2. RSI(3) <= SCALP_RSI_OVERSOLD: 단기 과매도 구간 진입 + 3. 이전 봉 음봉 → 최신 확정 봉 양봉: 되돌림(Retracement) 시작 신호 + 4. 당일 낙폭 필터 (SCALP_MIN_DROP_RATE 기준, 꼬리잡기보다 완화) + ※ 일일 회복률(50%) 필터는 제거 — RSI<25(폭락직후)와 모순이라 매수 기회를 모두 죽임 + 5. 피뢰침/급등 필터 + 6. 거래량 필터 (VOLUME_AVG_MULTIPLIER) + 7. 수급 수집 (ML 피처용, 진입 필터로 사용하지 않음) + ─ 당일 시고저: WS 캐시 우선 → REST fallback (API 과부하 방지) + """ + try: + # [테스트용] FORCE_BUY_TEST=true 시 조건 무시 + if get_env_bool("FORCE_BUY_TEST", False): + ws_d = self.ws_cache.get_price(code) if self.ws_cache else None + px = 0.0 + if ws_d: + px = abs(float(str(ws_d.get("stck_prpr", 0)))) + if px <= 0: + pd_ = self.client.inquire_price(code) + if pd_: + px = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", ""))) + if px > 0: + return {"code": code, "name": name, "price": px, + "score": 5.0, "entry_features": {}} + return None + + # ── 0. 시장 방향 필터 (USE_MARKET_REGIME_FILTER=true 시 활성) ── + # KODEX200(069500)/KOSDAQ150(229200) 60분봉 RSI로 상승장 확인 + # 하락장(ETF RSI < MARKET_REGIME_MIN_RSI)이면 롱 진입 차단 + if get_env_bool("USE_MARKET_REGIME_FILTER", False): + min_rsi = get_env_float("MARKET_REGIME_MIN_RSI", 48.0) + regime = self.db.get_market_regime(tf=60) + if not regime.get("is_bull") or regime.get("avg_rsi", 50) < min_rsi: + logger.debug( + "[시장필터] %s %s: 하락장 차단 (ETF RSI=%.1f < %.1f)", + code, name, regime.get("avg_rsi", 0), min_rsi, + ) + return None + + # ── 0b. 테마 과열 필터 (USE_THEME_HEAT_FILTER=true 시 활성) ── + # 이 종목 테마의 60분봉 RSI 평균이 THEME_HEAT_RSI_MAX 초과면 차단 + # "테마 전체가 과열인데 내가 지금 진입하면 상투 잡기" + if get_env_bool("USE_THEME_HEAT_FILTER", False): + heat_max = get_env_float("THEME_HEAT_RSI_MAX", 72.0) + meta = self.db.get_stock_meta(code) + if meta and meta.get("theme"): + momentum = self.db.get_theme_momentum(meta["theme"], tf=60) + if momentum.get("count", 0) >= 3 and momentum.get("avg_rsi3", 0) > heat_max: + logger.debug( + "[테마필터] %s %s: 테마(%s) 과열 차단 (RSI=%.1f > %.1f)", + code, name, meta["theme"], + momentum["avg_rsi3"], heat_max, + ) + return None + + # ── 1. RAM 버퍼에서 확정 봉 조회 (DB 조회 없음, 즉시 반영) ── + # fill_gap_from_rest() 가 RAM(_confirmed)에 즉시 쓰므로 + # 갭보정 직후에도 봉을 사용 가능 (DB 큐 플러시 대기 불필요) + if self.candle_agg: + candles = self.candle_agg.get_candles(code, self.candle_tf, n=50) + else: + # WebSocket 비활성 시 DB fallback + candles = self.db.get_ws_candles(code, self.candle_tf, + limit=50, confirmed_only=True) + if len(candles) < 5: + logger.info("%s🔍 [탈락-봉부족] %s %s: 확정봉 %d개 (최소 5개 필요)%s", + LOG_YELLOW, name, code, len(candles), LOG_RESET) + # ── 봉부족 감지 → 백그라운드 재갭보정 트리거 ────────────── + # 갭보정이 처음에 실패했거나 누락된 경우 즉시 재시도 + # 같은 종목 30초 내 중복 재시도 방지 (API 과부하 방지) + now_ts = time.time() + last_retry = self._gap_retry_ts.get(code, 0) + retry_interval = get_env_int("SCALP_GAP_RETRY_SEC", 30) + if now_ts - last_retry > retry_interval: + self._gap_retry_ts[code] = now_ts + def _retry_gap(c=code): + try: + tf = self.candle_tf + lim = get_env_int("SCALP_GAP_FILL_LIMIT", 100) + df = self.client.get_minute_chart(c, period=str(tf), limit=lim) + if df is not None and not df.empty and self.candle_agg: + self.candle_agg.fill_gap_from_rest(c, tf, df) + logger.info("🔧 [봉부족 재갭보정 완료] %s: %d행 로드", c, len(df)) + else: + logger.warning("⚠️ [봉부족 재갭보정 실패] %s: 데이터 없음", c) + except Exception as ex: + logger.warning("⚠️ [봉부족 재갭보정 오류] %s: %s", c, ex) + threading.Thread(target=_retry_gap, daemon=True, + name=f"gap-retry-{code}").start() + return None + + latest = candles[-1] # 가장 최신 확정 봉 + prev = candles[-2] # 그 전 봉 + + curr_price = float(latest["close"]) + if curr_price < self.min_price: + return None + + # ── 2. RSI 과열/과매도 체크 ─────────────────────────────── + # RSI(상대강도지수): 단기간 얼마나 오르고 내렸는지 나타내는 지표. + # SCALP_RSI_OVERSOLD(기본 25) 이하: 단기 극과매도 → 되돌림 가능성 ↑ + rsi3 = latest.get("rsi_3") + if rsi3 is None: + logger.info("%s🔍 [탈락-RSI없음] %s %s: RSI 미계산 (봉 축적 중)%s", + LOG_YELLOW, name, code, LOG_RESET) + return None + rsi3 = float(rsi3) + + # RSI 0.0 은 "극과매도"가 아니라 "봉 데이터 부족으로 계산 불가" + # → 신뢰할 수 없으므로 진입 차단 + if rsi3 <= 0.0: + logger.info("%s🔍 [탈락-RSI무효] %s %s: RSI3=0.0 (봉 부족, 계산 불가)%s", + LOG_YELLOW, name, code, LOG_RESET) + return None + + if rsi3 > self.rsi_overbought: + logger.info("%s🔍 [탈락-RSI과열] %s %s: RSI3=%.1f > %.0f%s", + LOG_YELLOW, name, code, rsi3, self.rsi_overbought, LOG_RESET) + return None + if rsi3 > self.rsi_oversold: + # 과매도 구간 아님 → 진입 기회 아님 + logger.info("%s🔍 [탈락-RSI조건] %s %s: RSI3=%.1f (과매도<%.0f 아님)%s", + LOG_YELLOW, name, code, rsi3, self.rsi_oversold, LOG_RESET) + return None + + # ── 3. 되돌림(Retracement) 봉 확인 ────────────────────── + # 되돌림: 하락하던 주가가 방향을 틀어 상승 전환하는 찰나의 타이밍 + # 이전 봉 음봉 + 현재 봉 양봉 → 전환 신호 확인 + prev_bearish = float(prev["close"]) < float(prev["open"]) + curr_bullish = float(latest["close"]) > float(latest["open"]) + if not (prev_bearish and curr_bullish): + logger.info("%s🔍 [탈락-되돌림없음] %s %s: prev_bear=%s curr_bull=%s%s", + LOG_YELLOW, name, code, prev_bearish, curr_bullish, LOG_RESET) + return None + + # ── 4. 당일 시고저 확보 (WS 캐시 우선 → REST fallback) ── + # H0STCNT0 체결 틱에 stck_oprc/hgpr/lwpr 포함 → REST 없이 바로 사용 + current_price = curr_price + day_open = day_high = day_low = 0.0 + ws_d = self.ws_cache.get_price(code) if self.ws_cache else None + if ws_d: + current_price = abs(float(str(ws_d.get("stck_prpr", curr_price)).replace(",", ""))) or curr_price + day_open = abs(float(str(ws_d.get("stck_oprc", 0)).replace(",", ""))) + day_high = abs(float(str(ws_d.get("stck_hgpr", 0)).replace(",", ""))) + day_low = abs(float(str(ws_d.get("stck_lwpr", 0)).replace(",", ""))) + + # WS 캐시 없거나 시고저 미수신 시에만 REST 호출 (API 과부하 방지) + if day_open <= 0 or day_low <= 0: + price_data = self.client.inquire_price(code) + if not price_data: + return None + current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) or current_price + day_open = abs(float(str(price_data.get("stck_oprc", 0)).replace(",", ""))) + day_high = abs(float(str(price_data.get("stck_hgpr", 0)).replace(",", ""))) + day_low = abs(float(str(price_data.get("stck_lwpr", 0)).replace(",", ""))) + + if day_open <= 0 or day_low <= 0 or current_price <= 0: + return None + + # ── 낙폭 필터 (SCALP_MIN_DROP_RATE, 기본 1.5%) ── + # 꼬리잡기 MIN_DROP_RATE(3%)보다 절반으로 완화 → 1분봉 타점 빈도 증가 + drop_rate = (day_open - day_low) / day_open if day_open > 0 else 0 + if drop_rate < self.scalp_min_drop_rate: + logger.info("%s🔍 [탈락-낙폭] %s %s: %.2f%% < %.1f%%(SCALP_MIN_DROP_RATE)%s", + LOG_YELLOW, name, code, + drop_rate * 100, self.scalp_min_drop_rate * 100, LOG_RESET) + return None + + # ── [핵심 변경] 일일 회복률 필터 제거 ───────────────────── + # 기존: recovery_pos_day >= MIN_RECOVERY_RATIO_SHORT(50%) 조건이 있었음 + # 문제: RSI<25(방금 폭락)이면 당연히 회복률도 낮음 → 동시 충족 불가 → 매수 기회 0 + # 스캘핑은 '폭락 직후 되돌림' 포착이 목적이므로 일일 회복률 체크 불필요 + + # ── 5. 피뢰침 / 급등 필터 ──────────────────────────────── + if current_price >= day_high * self.high_chase_thr: + logger.info("%s🔍 [탈락-고점추격] %s %s: 현재가 %.0f ≥ 고가 %.0f × %.2f%s", + LOG_YELLOW, name, code, + current_price, day_high, self.high_chase_thr, LOG_RESET) + return None + if day_low > 0: + # 당일 변동폭이 너무 크면 이미 급등주 → 스캘핑 회수 어려움 + daily_chg_pct = (day_high - day_low) / day_low * 100 + if daily_chg_pct > self.max_daily_chg: + logger.info("%s🔍 [탈락-피뢰침 급등주] %s %s: 일일 변동폭 %.1f%% > %.0f%%%s", + LOG_YELLOW, name, code, daily_chg_pct, self.max_daily_chg, LOG_RESET) + return None + + # ── 6. 거래량 필터 ──────────────────────────────────────── + # 현재 봉 거래량이 최근 N봉 평균 대비 VOLUME_AVG_MULTIPLIER 배 이상이어야 + # 단순 노이즈가 아닌 실제 수급 참여 신호로 판단 + volumes = [float(c.get("volume", 0)) for c in candles] + avg_vol = sum(volumes[:-1]) / max(len(volumes) - 1, 1) + curr_vol = float(latest.get("volume", 0)) + if avg_vol > 0 and curr_vol < avg_vol * self.vol_multiplier: + logger.info("%s🔍 [탈락-거래량] %s %s: %.0f < 평균%.0f × %.1f%s", + LOG_YELLOW, name, code, + curr_vol, avg_vol, self.vol_multiplier, LOG_RESET) + return None + + # ── 7. 수급 수집 (ML 피처용, 진입 필터로 미사용) ────────── + investor_trend = self.client.get_investor_trend(code, days=3) + + entry_features = { + "rsi": rsi3, + "volume_ratio": curr_vol / avg_vol if avg_vol > 0 else 1.0, + "drop_rate": drop_rate, # 당일 낙폭 (ML 학습용) + "tail_length_pct": 0.0, + "ma5_gap_pct": None, + "ma20_gap_pct": None, + "foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0, + "institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0, + "market_hour": dt.now().hour, + } + + # ── ML 승률 필터 (설정 시) ──────────────────────────────── + if self.use_ml_signal and self.ml_predictor: + try: + ml_feats = {k: (v if v is not None else 0.0) + for k, v in entry_features.items()} + ml_prob = self.ml_predictor.predict_win_probability(ml_feats) + if ml_prob < self.ml_min_prob: + logger.info("%s🔍 [탈락-ML] %s %s: %.2f%% < %.0f%%%s", + LOG_YELLOW, name, code, + ml_prob * 100, self.ml_min_prob * 100, LOG_RESET) + return None + except Exception: + pass + + # 과매도가 깊을수록(RSI 낮을수록) 점수 상승 → 우선 매수 + score = 5.0 + (self.rsi_oversold - rsi3) / 5.0 + logger.info( + "%s🎯 [스캘핑 시그널] %s | 가격:%.0f | RSI3:%.1f | 낙폭:%.1f%% | 거래량:%.1fx%s", + LOG_CYAN, name, current_price, rsi3, + drop_rate * 100, curr_vol / max(avg_vol, 1), LOG_RESET, + ) + return { + "code": code, + "name": name, + "price": current_price, + "score": score, + "entry_features": entry_features, + } + + except Exception as e: + logger.info("%s🔍 [탈락-예외] %s %s: %s%s", + LOG_YELLOW, name, code, e, LOG_RESET) + return None + + # ------------------------------------------------------------------ + # 매수 실행 (ver2 execute_buy 동일 구조) + # ------------------------------------------------------------------ + + def execute_buy(self, signal: dict) -> bool: + """매수 주문 실행 + DB 저장 + WS 구독.""" + code = signal["code"] + name = signal["name"] + price = signal["price"] + + if price <= 0: + return False + + # ── 포지션 크기 계산 (원화 손실 한도 역산) ──────────────────────────── + # MAX_LOSS_PER_TRADE_KRW(원) ÷ |SCALP_STOP_LOSS_PCT| = 최대 투자 가능 금액 + # 스캘핑 손절 1.5% 기준 예: 손실한도 20만원 → 투자상한 = 200,000 / 0.015 = 13,333,333원 + # SLOT_MONEY_DEFAULT 가 더 낮으면 그쪽 사용 (포지션 과집중 방지) + max_loss_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000) + # 스캘핑 전용 손절 비율 (SCALP_STOP_LOSS_PCT, 기본 1.5%) + scalp_sl_pct = abs(self.scalp_stop_loss_pct) # 양수로 처리 + if max_loss_krw > 0 and scalp_sl_pct > 0: + invest_limit = max_loss_krw / scalp_sl_pct + invest_amount = min(invest_limit, self.slot_money) + else: + invest_amount = self.slot_money + + qty = max(1, int(invest_amount / price)) + if qty <= 0: + return False + + # 스캘핑 전용 타이트한 손절/익절가 (SCALP_STOP/TAKE_PROFIT_PCT) + # 꼬리잡기의 STOP_LOSS_PCT(-4%), TAKE_PROFIT_PCT(+5%)와 완전 분리 + stop_price = price * (1 + self.scalp_stop_loss_pct) # ex: -1.5% + target_price = price * (1 + self.scalp_take_profit_pct) # ex: +1.5% + + ok = self.client.buy_order(code, qty, order_type="01") # 시장가 + if not ok: + return False + + now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S") + holding = { + "buy_price": price, + "qty": qty, + "stop_price": stop_price, + "target_price": target_price, + "max_price": price, + "atr_entry": 0.0, + "buy_time": now_str, + "name": name, + "size_class": "", + } + self.holdings[code] = holding + + # DB 저장 (upsert_trade 는 trade_data dict 방식) + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SCALP_RSI_REVERSAL", + "avg_buy_price": price, + "current_price": price, + "stop_price": stop_price, + "target_price": target_price, + "max_price": price, + "atr_entry": 0.0, + "target_qty": qty, + "current_qty": qty, + "total_invested": price * qty, + "status": "HOLDING", + "buy_date": now_str, + "entry_features": signal["entry_features"], + }) + + # WS 구독 (이미 구독 중이면 무시됨) + if self.ws_cache: + self.ws_cache.subscribe(code) + + rsi_val = signal["entry_features"].get("rsi", 0) + msg = (f"🛒 **[스캘핑 매수]** {name}({code})\n" + f"매수가: {price:,.0f}원 × {qty}주 = {price*qty:,.0f}원\n" + f"손절: {stop_price:,.0f}원(-{abs(self.scalp_stop_loss_pct)*100:.1f}%) | " + f"익절: {target_price:,.0f}원(+{self.scalp_take_profit_pct*100:.1f}%)\n" + f"RSI3: {rsi_val:.1f}") + msg_mm(msg) + logger.info("✅ [매수체결] %s %s @ %d원 × %d주 | 손절-%.1f%% 익절+%.1f%%", + name, code, int(price), qty, + abs(self.scalp_stop_loss_pct) * 100, self.scalp_take_profit_pct * 100) + return True + + # ------------------------------------------------------------------ + # 매도 신호 체크 (ver2 check_sell_signals 와 동일 원리) + # ------------------------------------------------------------------ + + def check_sell_signals(self) -> List[dict]: + """ + 보유 종목 순회 → 손절/익절/ATR 조건 체크. + 현재가: WebSocket 캐시 우선, 없으면 REST fallback. + """ + signals = [] + now = dt.now() + + for code, holding in list(self.holdings.items()): + try: + name = holding.get("name", code) + buy_price = float(holding.get("buy_price", 0)) + qty = int(holding.get("qty", 0)) + stop_price = float(holding.get("stop_price", 0)) + target_price = float(holding.get("target_price", 0)) + max_price = float(holding.get("max_price", buy_price)) + + if qty <= 0 or buy_price <= 0: + continue + + # 매도 백오프 중이면 스킵 + backoff_until = self._sell_backoff.get(code, 0) + if time.time() < backoff_until: + continue + + # 현재가 조회: WS 캐시 → REST fallback + current_price = 0.0 + if self.ws_cache and self.ws_cache.is_active: + pd_ = self.ws_cache.get_price(code) + if pd_: + current_price = abs(float(str(pd_.get("stck_prpr", 0)))) + if current_price <= 0: + pd_ = self.client.inquire_price(code) + if pd_: + current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", ""))) + if current_price <= 0: + continue + + # max_price 갱신 + if current_price > max_price: + max_price = current_price + holding["max_price"] = max_price + self.db.upsert_trade({ + "code": code, + "name": holding.get("name", code), + "avg_buy_price": buy_price, + "current_price": current_price, + "max_price": max_price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": holding.get("buy_time", now.strftime("%Y-%m-%d %H:%M:%S")), + }) + + profit_pct = (current_price - buy_price) / buy_price # 가격 변화율 (수수료 전) + profit_val = (current_price - buy_price) * qty # 가격 손익 원화 (수수료 전) + + # ── 본절가(breakeven) 계산 ────────────────────────────── + # 왕복 수수료 + 세금 + 최소 마진을 합산한 최소 보장 라인 + # FEE_RATE_PCT : 위탁수수료 매수/매도 각각 (기본 0.015%) + # SELL_TAX_RATE_PCT: 증권거래세 매도 시만 (기본 0.18%) + # SCALP_MIN_PROFIT_PCT: 수수료 위 최소 순이익 마진 (기본 0.2%) + # breakeven = 매수가 × (1 + 수수료×2 + 세금 + 최소마진) + _fee = get_env_float("FEE_RATE_PCT", 0.015) / 100 + _tax = get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100 + _min_margin = get_env_float("SCALP_MIN_PROFIT_PCT", 0.2) / 100 + breakeven_pct = _fee * 2 + _tax + _min_margin + breakeven_price = buy_price * (1 + breakeven_pct) + + reason = None + + # [1] 손절 (% 기준) + if current_price <= stop_price: + reason = f"손절 ({profit_pct*100:.2f}%)" + + # [2] 금액 손실컷 (원화 기준) — 고가 종목에서 % 손절 이전에 원화 손실이 터지는 경우 대비 + # 예) 10만원짜리 20주 보유, 손절 -3% → 20만원 손실 → 설정값 초과 시 먼저 컷 + elif profit_val <= -get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000): + reason = f"금액손실컷 ({profit_val:,.0f}원)" + + # [3] 익절 (% 기준) + elif current_price >= target_price: + reason = f"익절 ({profit_pct*100:.2f}%)" + + # [4] 본절사수 — 한 번 의미 있게 올랐던 종목이 수수료 라인까지 내려오면 + # 트레일링 전에 본절+0.2%에서 먼저 청산 (마이너스 방지) + # 조건: 고점이 본절가 이상이었던 적 있음 + 현재가가 본절가로 회귀 + elif max_price >= breakeven_price and current_price <= breakeven_price: + net_pct = profit_pct - breakeven_pct + _min_margin + reason = f"본절사수 (순익≈{net_pct*100:+.2f}%)" + + # [5] ATR 트레일링 스탑 (고점 대비 하락) + # 발동 조건: 고점이 매수가 대비 본절가 이상 올라야 함 + # → 수수료 방어 라인을 넘긴 수익에서만 트레일링 작동 + elif max_price >= breakeven_price: + drop_from_high = (max_price - current_price) / max_price + if drop_from_high >= self.atr_down_mult * 0.01: + # 트레일링 발동이어도 현재가가 본절 이하면 본절사수로 전환 + # (위 [4]에서 먼저 걸리므로 여기는 본절 이상에서만 도달) + reason = f"트레일링스탑 고점대비-{drop_from_high*100:.1f}%" + + if reason: + signals.append({ + "code": code, + "name": name, + "current_price": current_price, + "qty": qty, + "buy_price": buy_price, + "profit_pct": profit_pct, + "reason": reason, + }) + except Exception as e: + logger.error("매도 신호 체크 오류(%s): %s", code, e) + + return signals + + # ------------------------------------------------------------------ + # 매도 실행 + # ------------------------------------------------------------------ + + def execute_sell(self, signal: dict): + """매도 주문 실행 + DB 업데이트 + WS 구독 해제.""" + code = signal["code"] + name = signal["name"] + current_price = signal["current_price"] + qty = signal["qty"] + buy_price = signal["buy_price"] + profit_pct = signal["profit_pct"] + reason = signal["reason"] + + # 메시지에 사용할 손익·보유시간 미리 계산 (holdings 삭제 전) + # ── 수수료 계산 (env/DB 에서 비율 로드) ───────────────────────── + # FEE_RATE_PCT : 위탁수수료 (매수/매도 각각, 기본 0.015%) + # SELL_TAX_RATE_PCT: 증권거래세 (매도 시만 부과, 기본 0.18%) + # 왕복 총비용 = buy_price×qty×fee + sell_price×qty×(fee + tax) + fee_rate = get_env_float("FEE_RATE_PCT", 0.015) / 100 + tax_rate = get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100 + total_fee = (buy_price * qty * fee_rate + + current_price * qty * (fee_rate + tax_rate)) + gross_pnl = (current_price - buy_price) * qty # 수수료 제외 손익 + realized_pnl = gross_pnl - total_fee # 수수료 반영 순손익 + buy_time_str = (self.holdings.get(code) or {}).get("buy_time", + dt.now().strftime("%Y-%m-%d %H:%M:%S")) + try: + hold_min = int((dt.now() - dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S")).total_seconds() / 60) + except Exception: + hold_min = 0 + + ok = self.client.sell_market_order(code, qty) + if not ok: + msg_cd = self.client._last_sell_msg_cd or "" + msg1 = self.client._last_sell_msg1 or "" + # 영업일 아님/장 외 시간 오류 → 백오프 + if msg_cd in ("APBK0013", "APBK0962", "40910000"): + backoff = get_env_int("SELL_FAILURE_BACKOFF_SEC", 1800) + self._sell_backoff[code] = time.time() + backoff + logger.warning("⏳ [매도백오프] %s %s: %s → %d초 대기", + name, code, msg_cd, backoff) + # ── 잔고 없음: 계좌에 포지션이 없는데 로컬 DB·메모리에만 남은 경우 ── + # 모의투자 계정 초기화, 수동 취소 등으로 실제 잔고가 사라진 경우 + # 로컬 holdings·active_trades를 강제 정리해 무한 재시도 방지 + elif "잔고" in msg1 or "보유" in msg1 or "APBK3020" in msg_cd: + logger.warning("⚠️ [유령잔고 정리] %s %s: 브로커 잔고 없음 → 로컬 기록 강제 삭제", + name, code) + self.db.close_trade(code=code, sell_price=0, + sell_reason="잔고없음(강제정리)", + strategy="SCALP_RSI_REVERSAL") + self.holdings.pop(code, None) + return + + # DB: active_trades → trade_history 이동 (strategy 지정 → 스캘핑 row만 삭제) + # realized_pnl_override: 수수료·거래세 이미 차감된 순손익을 DB에 저장 + self.db.close_trade( + code=code, + sell_price=current_price, + sell_reason=reason, + strategy="SCALP_RSI_REVERSAL", + realized_pnl_override=realized_pnl, + ) + + if code in self.holdings: + del self.holdings[code] + + # WS 구독 해제 (재매수 대기 종목은 계속 구독) + self.recently_sold[code] = time.time() + + # 당일 확정 손익: 스캘핑(SCALP_) 전략만 필터 → 꼬리잡기 손익 혼합 방지 + try: + today_trades = self.db.get_trades_by_date(self.today_date) + day_pnl = sum( + (t.get("realized_pnl") or 0) + for t in today_trades + if str(t.get("strategy", "")).startswith("SCALP") + ) + except Exception: + day_pnl = 0 + + # 수수료 반영 순수익률 + net_pct = realized_pnl / (buy_price * qty) if buy_price * qty > 0 else 0 + emoji = "🔴" if realized_pnl < 0 else "🟢" + msg = (f"{emoji} **[스캘핑 매도]** {name}({code})\n" + f"{current_price:,.0f}원 × {qty:,}주 | {reason} | " + f"수익률 {net_pct*100:+.2f}% (실현 {realized_pnl:+,.0f}원 / 수수료 -{total_fee:,.0f}원)\n" + f"보유: {hold_min}분 | 보유 {len(self.holdings)}종목\n" + f"당일손익 {day_pnl:+,.0f}원") + msg_mm(msg) + logger.info("%s [매도체결] %s %s @ %d원 %+.2f%% (수수료 -%,.0f원) (%s)%s", + LOG_GREEN if realized_pnl >= 0 else LOG_RED, + name, code, int(current_price), net_pct * 100, total_fee, reason, LOG_RESET) + + # ------------------------------------------------------------------ + # 매인 루프 + # ------------------------------------------------------------------ + + def run(self): + """메인 루프 진입점.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """비동기 루프: 리포트 태스크 + 동기 매매 루프.""" + self._report_task = asyncio.create_task(self._report_scheduler()) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._sync_trading_loop) + + def _sync_trading_loop(self): + """동기 매매 루프.""" + logger.info("📈 스캘핑 매매 루프 시작") + last_cleanup_day = "" + + while True: + try: + self.reload_config() + now = dt.now() + today_str = now.strftime("%Y-%m-%d") + + # 날짜 변경 처리 + if today_str != self.today_date: + self.today_date = today_str + self.untradable_skip_set.clear() + self.morning_report_sent = False + self.closing_report_sent = False + logger.info("📅 날짜 변경: %s", today_str) + + # ws_candles 오래된 봉 정리 (1일 1회) + if today_str != last_cleanup_day: + keep = get_env_int("SCALP_CANDLE_KEEP_DAYS", 3) + self.db.cleanup_old_ws_candles(keep_days=keep) + last_cleanup_day = today_str + + # 장 외 시간: 보유 없으면 슬립 + if not self.check_market_status(): + time.sleep(30) + continue + + # ── 매도 우선 ───────────────────────────────────────── + sell_signals = self.check_sell_signals() + for sig in sell_signals: + self.execute_sell(sig) + + # ── 후보 종목 동기화 (새 종목 구독) ────────────────── + candidates = self.db.get_target_candidates() + self._sync_subscriptions(candidates) + + # ── 매수 체크 ───────────────────────────────────────── + active_cnt = len(self.holdings) + if candidates and active_cnt < self.max_stocks: + logger.info("🔍 [매수체크] 후보 %d개 순회 (보유 %d/%d)", + len(candidates), active_cnt, self.max_stocks) + for c in candidates: + code = c.get("code") or c.get("stk_cd", "") + name = c.get("name") or c.get("stk_nm", code) + if not code or code in self.holdings: + continue + if code in self.untradable_skip_set: + continue + + # 재진입 쿨다운 (스캘핑 전용 SCALP_COOLDOWN_SEC → 없으면 REENTRY_COOLDOWN_SEC 폴백) + reentry_cd = get_env_int("SCALP_COOLDOWN_SEC", None) or get_env_int("REENTRY_COOLDOWN_SEC", 300) + elapsed = time.time() - self.recently_sold.get(code, 0) + if elapsed < reentry_cd: + remaining = int(reentry_cd - elapsed) + logger.info("⏳ [재진입차단] %s %s — %d초 남음", + name, code, remaining) + continue + + signal = self.check_buy_signal_scalp(code, name) + if signal: + ok = self.execute_buy(signal) + if ok: + time.sleep(random.uniform(1, 2)) + break + time.sleep(random.uniform(0.3, 0.8)) + continue + time.sleep(random.uniform(0.2, 0.5)) + + # 스캘핑 루프: 짧은 대기 (1분봉 집계 시 1~2초면 충분) + time.sleep(random.uniform(1, 2)) + + except KeyboardInterrupt: + logger.info("⏹ 봇 종료 (KeyboardInterrupt)") + if self._report_task: + self._report_task.cancel() + break + except Exception as e: + logger.error("❌ 루프 에러: %s", e) + time.sleep(5) + + # ------------------------------------------------------------------ + # 리포트 스케줄러 + # ------------------------------------------------------------------ + + async def _report_scheduler(self): + """장 시작(9:05) / 마감(15:35) 리포트 전송.""" + while True: + try: + now = dt.now() + if (now.hour == 9 and now.minute == 5 and not self.morning_report_sent): + self._send_report("morning") + self.morning_report_sent = True + elif (now.hour == 15 and now.minute == 35 and not self.closing_report_sent): + self._send_report("closing") + self.closing_report_sent = True + except Exception as e: + logger.error("리포트 스케줄러 오류: %s", e) + await asyncio.sleep(30) + + def _send_report(self, report_type: str): + """간단한 보유 현황 리포트 전송.""" + try: + now_str = dt.now().strftime("%Y-%m-%d %H:%M") + title = "🌅 장 시작 보유현황" if report_type == "morning" else "📊 마감 스캘핑 리포트" + lines = [f"**{title}** ({now_str})\n"] + + if self.holdings: + for code, h in self.holdings.items(): + price_data = self.client.inquire_price(code) + curr = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) \ + if price_data else h.get("buy_price", 0) + pnl_pct = (curr - h["buy_price"]) / h["buy_price"] * 100 if h["buy_price"] > 0 else 0 + lines.append(f"- {h['name']}({code}): {curr:,.0f}원 {pnl_pct:+.1f}%") + time.sleep(0.2) + else: + lines.append("- 보유 종목 없음") + + # 오늘 매매 결과 — 스캘핑(SCALP_) 전략만 집계 + today_yyyymmdd = dt.now().strftime("%Y%m%d") + all_today = self.db.get_trades_by_date(today_yyyymmdd) + history = [t for t in all_today + if str(t.get("strategy", "")).startswith("SCALP")] + if history: + wins = [t for t in history if (t.get("profit_rate") or 0) >= 0] + total_pnl = sum((t.get("realized_pnl") or 0) for t in history) + lines.append(f"\n오늘 매매: {len(history)}건 | 승{len(wins)}/패{len(history)-len(wins)} | " + f"손익 {total_pnl:+,.0f}원") + + msg_mm("\n".join(lines)) + except Exception as e: + logger.error("리포트 전송 실패: %s", e) + + +# ══════════════════════════════════════════════════════════════════════ +if __name__ == "__main__": + bot = ScalpingBotV1() + bot.run() diff --git a/kis_scalping_ver2.py b/kis_scalping_ver2.py new file mode 100644 index 0000000..eb9a041 --- /dev/null +++ b/kis_scalping_ver2.py @@ -0,0 +1,1589 @@ +""" +kis_scalping_ver2.py — 스캘핑 봇 V2 (공통 엔진 사용, 단독 실행) +════════════════════════════════════════════════════════════ +- ver1과 동일한 구조·실매매 로직이지만, 매수 판단만 scalping_engine.check_buy_signal_live 사용. +- 백테스트·파라미터서치와 계산식 동일 → 엔진에 없는 실매매 전용 필터(고점추격/급등/본절사수/금액손실컷)만 추가. +- kis_scalping_ver1 임포트 없이 단독 실행 (코드 전부 포함). +""" + +from __future__ import annotations + +import asyncio +import datetime +import json +import logging +import os +import random +import threading +import time +from datetime import datetime as dt +from pathlib import Path +from typing import Dict, List, Optional + +import pandas as pd +import requests + +from database import TradeDB, ENV_CONFIG_KEYS +import scalping_engine as se + +# WebSocket + CandleAggregator + 키움 분봉 갭보정 유틸 +try: + from kis_ws import ( + KISWebSocketPriceCache, CandleAggregator, + get_kiwoom_candles_df, _get_kiwoom_creds, + ) + _KIS_WS_AVAILABLE = True +except ImportError: + _KIS_WS_AVAILABLE = False + +# ML 예측 (선택적) +try: + from ml_predictor import MLPredictor + ML_AVAILABLE = True +except ImportError: + ML_AVAILABLE = False + +# RiskManager (선택적) +try: + from risk_manager import RiskManager + RISK_MANAGER_AVAILABLE = True +except ImportError: + RISK_MANAGER_AVAILABLE = False + +# ── 로깅 ────────────────────────────────────────────────────────────── +logging.basicConfig( + format="[%(asctime)s] %(message)s", + datefmt="%H:%M:%S", + level=logging.INFO, +) +logger = logging.getLogger("ScalpBot") + +LOG_RED = "\033[91m" +LOG_YELLOW = "\033[93m" +LOG_GREEN = "\033[92m" +LOG_CYAN = "\033[96m" +LOG_RESET = "\033[0m" + +# ── DB 초기화 (MariaDB — 접속 정보는 database.py 모듈 상수 또는 환경변수로 설정) ── +SCRIPT_DIR = Path(__file__).resolve().parent +db = TradeDB() # db_path 인수 무시됨, MariaDB 192.168.0.141 직접 연결 + +# ── 환경변수 헬퍼 ───────────────────────────────────────────────────── +def get_env_from_db(key, default=""): + 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): + 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): + 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): + value = get_env_from_db(key, str(default)).lower() + return value in ("true", "1", "yes") + +# ── Mattermost 설정 ─────────────────────────────────────────────────── +MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org") +MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json" +MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "scalping") +MM_CHANNEL_SCALP = get_env_from_db("KIS_SCALP_MM_CHANNEL", MM_CHANNEL_DEFAULT) + +# ── Gemini ─────────────────────────────────────────────────────────── +try: + import google.genai as genai + GEMINI_AVAILABLE = True +except ImportError: + GEMINI_AVAILABLE = False + +GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip() +gemini_client = None +if GEMINI_API_KEY and GEMINI_AVAILABLE: + try: + gemini_client = genai.Client(api_key=GEMINI_API_KEY) + except Exception: + gemini_client = None + +# ── 토큰 캐시 ───────────────────────────────────────────────────────── +KIS_TOKEN_CACHE_PATH_MOCK = SCRIPT_DIR / ".kis_token_cache_mock.json" +KIS_TOKEN_CACHE_PATH_REAL = SCRIPT_DIR / ".kis_token_cache_real.json" +KIS_TOKEN_EXPIRE_MARGIN_SEC = 60 + + +def _parse_kis_token_expired(expired_str): + if not expired_str or not isinstance(expired_str, str): + return None + s = expired_str.strip().replace("T", " ")[:19] + try: + return dt.strptime(s, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def _load_kis_token_cache(mock): + path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL + if not path.exists(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + cache = json.load(f) + if cache.get("mock") != mock: + return None + token = cache.get("access_token") + expired_dt = _parse_kis_token_expired( + cache.get("access_token_token_expired") or cache.get("expires_at") + ) + if not token or not expired_dt: + return None + margin = datetime.timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC) + if dt.now() >= expired_dt - margin: + logger.info("한투 토큰 만료 임박/만료 → 재발급") + return None + logger.info("한투 토큰 캐시 재사용 (만료: %s)", expired_dt.strftime("%H:%M:%S")) + return {"access_token": token, "expires_at": expired_dt.isoformat()} + except Exception: + return None + + +def _save_kis_token_cache(mock, token, expired_str): + path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL + try: + with open(path, "w", encoding="utf-8") as f: + json.dump({"mock": mock, "access_token": token, + "access_token_token_expired": expired_str}, f) + except Exception as e: + logger.warning("한투 토큰 캐시 저장 실패: %s", e) + + +# ══════════════════════════════════════════════════════════════════════ +# KIS REST 클라이언트 (ver2 에서 직접 이식 — 구조 동일) +# ══════════════════════════════════════════════════════════════════════ +class KISClient: + """한국투자증권 REST API 클라이언트. ver2 KISClient 와 동일 구조.""" + + def __init__(self, app_key, app_secret, account_no, account_code, mock=True): + self.app_key = app_key + self.app_secret = app_secret + self.account_no = account_no + self.account_code = account_code + self.mock = mock + self.base_url = ( + "https://openapivts.koreainvestment.com:29443" + if mock else + "https://openapi.koreainvestment.com:9443" + ) + self._token: Optional[str] = None + self._token_lock = __import__("threading").Lock() + # API 호출 간 최소 간격 (429 방지) + self._last_call_ts: float = 0.0 + self._min_interval: float = 0.22 # 초 (초당 ~4건 안전 마진) + # 마지막 매도 오류 캐시 (execute_sell 에서 사용) + self._last_sell_msg_cd: Optional[str] = None + self._last_sell_msg1: Optional[str] = None + self._init_token() + + def _init_token(self): + cache = _load_kis_token_cache(self.mock) + if cache: + self._token = cache["access_token"] + else: + # kis_token_manager 경로로만 발급 (잠금·1일1회 준수, SMS 시각 안정화) + try: + from kis_token_manager import ensure_token + if ensure_token(self.mock): + cache = _load_kis_token_cache(self.mock) + self._token = cache["access_token"] if cache else None + except Exception as e: + logger.warning("kis_token_manager 발급 실패: %s", e) + self._token = None + + def _issue_token(self) -> Optional[str]: + try: + r = requests.post( + f"{self.base_url}/oauth2/tokenP", + json={"grant_type": "client_credentials", + "appkey": self.app_key, "appsecret": self.app_secret}, + timeout=10, + ) + j = r.json() + token = j.get("access_token") + if token: + _save_kis_token_cache(self.mock, token, + j.get("access_token_token_expired", "")) + logger.info("✅ 한투 토큰 발급 완료") + return token + except Exception as e: + logger.error("❌ 한투 토큰 발급 실패: %s", e) + return None + + def _throttle(self): + """ + API 호출 전 처리: + 1. 토큰 만료 10분 전 선제 갱신 (KisTokenManager) + 2. 최소 호출 간격 대기 (429 방지) + """ + # 토큰 갱신 체크 (유효하면 메모리 반환, 만료 임박 시만 재발급) + try: + from kis_token_manager import KisTokenManager + fresh = KisTokenManager.instance(is_mock=self.mock).get_token() + if fresh: + self._token = fresh + except Exception: + pass + elapsed = time.time() - self._last_call_ts + if elapsed < self._min_interval: + time.sleep(self._min_interval - elapsed) + self._last_call_ts = time.time() + + def _get(self, path, tr_id, params, retry=5): + """GET 요청 + EGW00201 자동 재시도.""" + self._throttle() + headers = { + "authorization": f"Bearer {self._token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + "custtype": "P", + } + url = self.base_url + path + for attempt in range(1, retry + 1): + try: + r = requests.get(url, headers=headers, params=params, timeout=10) + if r.status_code == 200: + j = r.json() + if j.get("msg_cd") == "EGW00201": + wait = 1.5 + logger.warning( + "⏳ API 초당거래 초과 (EGW00201) GET %s -> %.1f초 대기 후 재시도 (%d/%d) msg1=%s", + path, wait, attempt, retry, j.get("msg1", ""), + ) + time.sleep(wait) + continue + return r + except requests.exceptions.Timeout: + logger.warning("⏰ GET %s 타임아웃 (%d/%d)", path, attempt, retry) + time.sleep(1.0) + return requests.Response() + + def _post(self, path, tr_id, body, retry=3): + """POST 요청 + EGW00201 자동 재시도.""" + self._throttle() + headers = { + "authorization": f"Bearer {self._token}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + "content-type": "application/json; charset=utf-8", + "custtype": "P", + } + url = self.base_url + path + for attempt in range(1, retry + 1): + try: + r = requests.post(url, headers=headers, json=body, timeout=10) + if r.status_code == 200: + j = r.json() + if j.get("msg_cd") == "EGW00201": + wait = 1.5 + logger.warning( + "⏳ API 초당거래 초과 (EGW00201) POST %s -> %.1f초 대기 (%d/%d)", + path, wait, attempt, retry, + ) + time.sleep(wait) + continue + return r + except requests.exceptions.Timeout: + logger.warning("⏰ POST %s 타임아웃 (%d/%d)", path, attempt, retry) + time.sleep(1.0) + return requests.Response() + + def inquire_price(self, code: str) -> Optional[dict]: + """현재가 조회 [v1_국내주식-007]""" + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-price", + "FHKST01010100", + {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code}, + ) + if r.status_code != 200: + return None + j = r.json() + return j.get("output") if j.get("rt_cd") == "0" else None + + def get_minute_chart(self, code: str, period: str = "1", limit: int = 100) -> pd.DataFrame: + """ + 분봉 차트 조회 [v1_국내주식-017] — 재접속 갭 보정용. + 스캘핑봇은 이 함수를 정상 운영 중에는 호출하지 않음. + """ + path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + tr_id = "FHKST03010200" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=1) + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_HOUR_1": start_dt.strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": end_dt.strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + "FID_ETC_CLS_CODE": "", + } + r = self._get(path, tr_id, params) + if r.status_code != 200: + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + return pd.DataFrame() + data = j.get("output2", []) + if not data: + return pd.DataFrame() + rows = [] + for item in data: + try: + date_str = str(item.get("stck_bsop_date", "") or "") + time_str = str(item.get("stck_cntg_hour", "") or "000000") + rows.append({ + "time": date_str + time_str[:4], # YYYYMMDDHHMM + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except Exception: + continue + if not rows: + return pd.DataFrame() + df = pd.DataFrame(rows) + df = df.sort_values("time").reset_index(drop=True) + return df.tail(limit) + except Exception as e: + logger.error("분봉 조회 실패(%s): %s", code, e) + return pd.DataFrame() + + def get_investor_trend(self, code: str, days: int = 3) -> Optional[dict]: + """투자자 동향 조회 (수급 확인용)""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-investor", + "FHKST01010900", + {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code, + "FID_INPUT_DATE_1": (dt.now() - datetime.timedelta(days=days)).strftime("%Y%m%d"), + "FID_INPUT_DATE_2": dt.now().strftime("%Y%m%d"), + "FID_PERIOD_DIV_CODE": "D"}, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + output = j.get("output", []) + if not output: + return None + foreign_net = sum(int(str(o.get("frgn_ntby_qty", 0)).replace(",", "")) + for o in output if o) + org_net = sum(int(str(o.get("orgn_ntby_qty", 0)).replace(",", "")) + for o in output if o) + return {"foreign_net_buy": foreign_net, "org_net_buy": org_net} + except Exception as e: + logger.debug("투자자 동향 조회 실패(%s): %s", code, e) + return None + + def get_account_balance(self) -> Optional[dict]: + """계좌 잔고 조회""" + tr_id = "VTTC8434R" if self.mock else "TTTC8434R" + try: + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance", + tr_id, + {"CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, + "AFHR_FLPR_YN": "N", "OFL_YN": "N", "INQR_DVSN": "01", + "UNPR_DVSN": "01", "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", "PRCS_DVSN": "00", + "CTX_AREA_FK100": "", "CTX_AREA_NK100": ""}, + ) + if r.status_code != 200: + return None + j = r.json() + return j if j.get("rt_cd") == "0" else None + except Exception as e: + logger.error("계좌 잔고 조회 실패: %s", e) + return None + + def buy_order(self, code: str, qty: int, price: int = 0, + order_type: str = "01") -> bool: + """매수 주문 (01=시장가, 00=지정가)""" + tr_id = "VTTC0802U" if self.mock else "TTTC0802U" + path = "/uapi/domestic-stock/v1/trading/order-cash" + try: + body = { + "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, + "PDNO": code, "ORD_DVSN": order_type, + "ORD_QTY": str(qty), "ORD_UNPR": str(price), + } + r = self._post(path, tr_id, body) + if r.status_code != 200: + return False + j = r.json() + if j.get("rt_cd") == "0": + logger.info("✅ 매수주문 성공: %s %d주 @ %d원", code, qty, price) + return True + logger.error("❌ 매수주문 실패: %s | msg=%s", code, j.get("msg1", "")) + return False + except Exception as e: + logger.error("매수 주문 예외(%s): %s", code, e) + return False + + def sell_order(self, code: str, qty: int, price: int = 0, + order_type: str = "01") -> bool: + """매도 주문 (01=시장가, 00=지정가)""" + tr_id = "VTTC0801U" if self.mock else "TTTC0801U" + path = "/uapi/domestic-stock/v1/trading/order-cash" + try: + body = { + "CANO": self.account_no, "ACNT_PRDT_CD": self.account_code, + "PDNO": code, "ORD_DVSN": order_type, + "ORD_QTY": str(qty), "ORD_UNPR": str(price), + } + r = self._post(path, tr_id, body) + if r.status_code != 200: + return False + j = r.json() + if j.get("rt_cd") == "0": + return True + self._last_sell_msg_cd = j.get("msg_cd", "") + self._last_sell_msg1 = j.get("msg1", "") + logger.error("❌ 매도주문 실패: %s | msg=%s", code, self._last_sell_msg1) + return False + except Exception as e: + self._last_sell_msg_cd = None + self._last_sell_msg1 = None + logger.error("매도 주문 예외(%s): %s", code, e) + return False + + def sell_market_order(self, code: str, qty: int) -> bool: + return self.sell_order(code, qty, price=0, order_type="01") + + +# ══════════════════════════════════════════════════════════════════════ +# 알림 헬퍼 +# ══════════════════════════════════════════════════════════════════════ +def _load_mm_channel_id(channel_alias: str) -> Optional[str]: + try: + if MM_CONFIG_FILE.exists(): + with open(MM_CONFIG_FILE, "r", encoding="utf-8") as f: + cfg = json.load(f) + return cfg.get("channels", {}).get(channel_alias) + except Exception: + pass + return None + + +def msg_mm(text: str, channel_alias: str = None) -> None: + """Mattermost 알림 전송.""" + alias = channel_alias or MM_CHANNEL_SCALP + cid = _load_mm_channel_id(alias) + if not cid or not MM_BOT_TOKEN: + logger.debug("[MM 알림 스킵] channel=%s cid=%s", alias, cid) + return + try: + requests.post( + f"{MM_SERVER_URL}/api/v4/posts", + headers={"Authorization": f"Bearer {MM_BOT_TOKEN}", + "Content-Type": "application/json"}, + json={"channel_id": cid, "message": text}, + timeout=5, + ) + except Exception as e: + logger.debug("MM 알림 실패: %s", e) + + +# ══════════════════════════════════════════════════════════════════════ +# 스캘핑 봇 메인 클래스 +# ══════════════════════════════════════════════════════════════════════ +class ScalpingBotV2: + """ + WebSocket 봉 집계 기반 초단타 스캘핑 봇. + + 매수 조건 (AND 조건) + ───────────────────── + 1. 장 시작 후 SCALP_MARKET_OPEN_WAIT_MIN 분 경과 + 2. 당일 낙폭 >= MIN_DROP_RATE (ver2 동일) + 3. 저점 회복률 >= MIN_RECOVERY_RATIO_SHORT (ver2 동일) + 4. RSI(3) <= SCALP_RSI_OVERSOLD (과매도 구간) + 5. 이전 봉 close < open (하락) → 현재 봉 close > open (반등 시작) + 6. MA5 위에 있거나, 현재봉 거래량 >= 평균 거래량 × VOLUME_AVG_MULTIPLIER + 7. RSI(3) >= SCALP_RSI_OVERBOUGHT 시 진입 금지 (고점 추격 방지) + + 매도 조건 (ver2 check_sell_signals 와 동일) + ──────────────────────────────────────────── + - 손절: 현재가 <= stop_price + - 익절: 현재가 >= target_price + - 숄더컷, 퀵프로핏, ATR 트레일링 스탑 + """ + + def __init__(self): + # ── KIS 인증 정보 (DB 에서 로드) ───────────────────────── + is_mock = get_env_bool("KIS_MOCK", True) + if is_mock: + app_key = get_env_from_db("KIS_APP_KEY_MOCK", "") + app_secret = get_env_from_db("KIS_APP_SECRET_MOCK", "") + account_no = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "") + account_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "01") + else: + app_key = get_env_from_db("KIS_APP_KEY_REAL", "") + app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "") + account_no = get_env_from_db("KIS_ACCOUNT_NO_REAL", "") + account_code = get_env_from_db("KIS_ACCOUNT_CODE_REAL", "01") + + self.client = KISClient(app_key, app_secret, account_no, account_code, mock=is_mock) + self.db = db + self.mm_channel = MM_CHANNEL_SCALP + + # ── 보유 종목 & 상태 ───────────────────────────────────── + self.holdings: Dict[str, dict] = {} + self.untradable_skip_set: set = set() + self.recently_sold: dict = {} + self._sell_backoff: dict = {} + self.today_date = dt.now().strftime("%Y-%m-%d") + # 갭보정 실패 재시도 대기열: 신규 구독 시 갭보정이 실패한 종목 코드 집합 + # check_buy_signal_scalp에서 봉부족 감지 시 자동으로 백그라운드 재갭보정 트리거 + self._gap_retry_queue: set = set() + # 봉부족 종목별 마지막 재갭보정 시각 (같은 종목 30초 내 중복 재시도 방지) + self._gap_retry_ts: dict = {} + + # ── 자산 추적 ───────────────────────────────────────────── + self.current_cash = 0.0 + self.current_total_asset = 0.0 + self.start_day_asset = 0.0 + + # ── 리포트 플래그 ───────────────────────────────────────── + self.morning_report_sent = False + self.closing_report_sent = False + + # ── 설정 로드 ───────────────────────────────────────────── + self.reload_config() + + # ── DB 에서 활성 포지션 복구 (SCALP_ 전략만) ────────────── + active_trades = self.db.get_active_trades(strategy_prefix="SCALP") + for code, trade in active_trades.items(): + self.holdings[code] = { + "buy_price": trade.get("avg_buy_price", 0), + "qty": trade.get("current_qty", 0), + "stop_price": trade.get("stop_price", 0), + "target_price": trade.get("target_price", 0), + "max_price": trade.get("max_price", 0), + "atr_entry": trade.get("atr_entry", 0), + "buy_time": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), + "name": trade.get("name", code), + "size_class": trade.get("size_class", ""), + } + + # ── WebSocket + CandleAggregator 초기화 ────────────────── + self.ws_cache: Optional[KISWebSocketPriceCache] = None + self.candle_agg: Optional[CandleAggregator] = None + self._init_websocket() + + # ── ML / RiskManager (선택적) ───────────────────────────── + self.ml_predictor = None + if ML_AVAILABLE and get_env_bool("USE_ML_SIGNAL", False): + try: + self.ml_predictor = MLPredictor() + except Exception as e: + logger.warning("ML 초기화 실패: %s", e) + + self.risk_manager = None + if RISK_MANAGER_AVAILABLE: + try: + self.risk_manager = RiskManager( + risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.015), + max_position_pct = get_env_float("MAX_POSITION_PCT", 0.03), + min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 20000), + use_kelly = get_env_bool("USE_KELLY", False), + kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25), + slot_base_amount_cap= get_env_int("SLOT_BASE_AMOUNT_CAP", 0), + # ── 무조건 깔고 가는 MAX_LOSS 기반 투자 상한 ───────── + max_loss_per_trade_krw=get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000), + stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.03), + # ── 사이즈 클래스별 비율 (DB에서 주입) ─────────────── + size_small_ratio = get_env_float("SIZE_CLASS_SMALL_RATIO", 0.70), + size_mid_ratio = get_env_float("SIZE_CLASS_MID_RATIO", 0.85), + ) + except Exception as e: + logger.warning("RiskManager 초기화 실패: %s", e) + + # ── 백그라운드 태스크 핸들 ──────────────────────────────── + self._report_task = None + + logger.info("🚀 ScalpingBotV2 초기화 완료 (mock=%s, holdings=%d)", + is_mock, len(self.holdings)) + + # ------------------------------------------------------------------ + # WebSocket + CandleAggregator 초기화 + # ------------------------------------------------------------------ + + def _init_websocket(self): + """WebSocket 시작 → CandleAggregator 연결 → 종목 구독 → 갭 보정.""" + if not _KIS_WS_AVAILABLE: + logger.warning("⚠️ kis_ws 모듈 없음 → WebSocket 비활성") + return + + try: + # [수정] 웹소켓은 데이터 수신용이므로 무조건 실전(Real) 서버를 타도록 하이브리드 구성 + ws_app_key = get_env_from_db("KIS_APP_KEY_REAL", "") + ws_app_secret = get_env_from_db("KIS_APP_SECRET_REAL", "") + + self.ws_cache = KISWebSocketPriceCache( + app_key = ws_app_key if ws_app_key else self.client.app_key, + app_secret = ws_app_secret if ws_app_secret else self.client.app_secret, + is_mock = False, # 실전 서버 강제 접속 + ) + + # CandleAggregator 생성 후 WS 에 연결 + # 1분봉(주전략) + 3분봉 + 15분봉 + 60분봉 — 나중에 다른 전략 필터로 활용 가능 + tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1) + self.candle_agg = CandleAggregator(db=self.db, timeframes=[tf, 3, 15, 60]) + self.ws_cache.attach_candle_aggregator(self.candle_agg) + + ws_ok = self.ws_cache.start() + if not ws_ok: + logger.warning("⚠️ WebSocket 시작 실패 → 스캘핑봇은 WS 없이 동작 불가") + self.ws_cache = None + self.candle_agg = None + return + + # 기존 보유 종목 구독 + for code in list(self.holdings.keys()): + self.ws_cache.subscribe(code) + + # 키움봇 후보 종목도 미리 구독 + candidates = self.db.get_target_candidates() + for c in candidates: + code = c.get("code") or c.get("stk_cd", "") + if code: + self.ws_cache.subscribe(code) + + # ── 영구 구독 ETF: 시장 방향 필터용 (유니버스 변경과 무관하게 항상 유지) ── + # KOSPI(069500), KOSDAQ(229200) 지수 ETF의 60분봉 RSI → 상승장/하락장 판단 + perm_raw = get_env_from_db("PERMANENT_WS_CODES", "069500,229200") + self._permanent_ws_codes: set = { + c.strip() for c in str(perm_raw).split(",") if c.strip() + } + for code in sorted(self._permanent_ws_codes): + self.ws_cache.subscribe(code) + logger.info("📡 [영구구독] %s (시장방향 ETF)", code) + + logger.info("✅ WebSocket + CandleAggregator 활성 (%d종목 구독)", + len(self.ws_cache._subscribed)) + + # WS 연결 성공 시마다 갭보정 자동 실행 등록 + # (장 시간 첫 연결 시 REST 분봉 로드 → 봉부족 해소) + # 새벽 자동재연결 시에는 kis_ws 내부에서 is_market_hours() 체크 후 스킵 + self.ws_cache.set_on_connected_callback(self._fill_all_gaps) + + # 시작 시 즉시 한 번 실행 (장 중 재시작 대비) + self._fill_all_gaps() + + except Exception as e: + logger.warning("⚠️ WebSocket 초기화 예외: %s", e) + self.ws_cache = None + self.candle_agg = None + + def _fill_all_gaps(self): + """ + 봇 시작 시 또는 WS 재접속 시 모든 구독 종목의 봉 갭을 REST 로 보정. + RAM 버퍼(_confirmed)에 즉시 적재 → 다음 매수 체크에 즉시 반영. + DB는 백그라운드 큐로 비동기 저장. + + ▶ 키움 우선 전략: + - 키움 ka10080 은 1회 호출에 최대 900봉(≈6개월치) 제공 → 장 초반에도 즉시 봉 확보 가능 + - KIS get_minute_chart 는 당일봉만 제공 → 장 시작 직후 봉 부족 → 키움 우선 + - 키움 키 없으면 KIS fallback (1분/3분봉만, 15/60분봉은 KIS 지원 안 함) + """ + if not self.candle_agg or not self.ws_cache: + return + # ── 중복 실행 방지 ───────────────────────────────────────────── + if getattr(self, '_gap_filling', False): + logger.debug("갭보정 이미 진행 중 → 스킵") + return + self._gap_filling = True + try: + main_tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1) + limit = get_env_int("SCALP_GAP_FILL_LIMIT", 120) + with self.ws_cache._sub_lock: + codes = set(self.ws_cache._subscribed) + + # ── 키움 크레덴셜 조회 ──────────────────────────────────── + kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db) + use_kiwoom = bool(kw_key and kw_secret) + logger.info( + "🔧 [갭보정] %d종목 분봉 로드 시작 (tfs=%s, limit=%d, kiwoom=%s)", + len(codes), self.candle_agg.timeframes, limit, "✅" if use_kiwoom else "❌→KIS fallback", + ) + + for code in sorted(codes): + for tf in self.candle_agg.timeframes: + df = None + # 키움 우선: 어제 봉 포함, 장 초반에도 봉 바로 확보 + # ※ 토큰은 _get_kiwoom_token_cached()가 23시간 캐시 → au10001 한도 방지 + if use_kiwoom: + try: + df = get_kiwoom_candles_df( + code, tf, kw_key, kw_secret, + is_mock=kw_mock, n=limit, + ) + except Exception as e: + logger.debug("키움 갭보정 실패 (%s %dM): %s", code, tf, e) + + # KIS fallback: 당일봉만 → 1분/3분봉에만 유효 + if (df is None or df.empty) and tf <= 3: + try: + df = self.client.get_minute_chart( + code, period=str(tf), limit=limit + ) + except Exception as e: + logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e) + + if df is not None and not df.empty: + self.candle_agg.fill_gap_from_rest(code, tf, df) + # 같은 종목 내 timeframe 전환: 짧은 딜레이 (차트 API 레이트리밋) + time.sleep(random.uniform(0.2, 0.4)) + # 종목 간 딜레이: 조금 더 길게 + time.sleep(random.uniform(0.3, 0.6)) + finally: + self._gap_filling = False + + # ------------------------------------------------------------------ + # 설정 리로드 + # ------------------------------------------------------------------ + + def reload_config(self): + """DB 설정 실시간 반영 (메인 루프마다 호출).""" + # ── 스캘핑 전용 손익절: 꼬리잡기(STOP_LOSS_PCT/TAKE_PROFIT_PCT)와 분리 ── + # SCALP_STOP_LOSS_PCT : 스캘핑 손절 % (양수, 기본 1.5%) + # 1분봉 초단타에서 -4% 손절은 너무 넓어 자금이 묶임 → 1.5%로 타이트하게 설정 + # SCALP_TAKE_PROFIT_PCT: 스캘핑 익절 % (양수, 기본 1.5%) + # +5% 익절은 1분봉에서 거의 도달 불가 → 1.5%로 줄여 회전율 극대화 + scalp_sl = get_env_float("SCALP_STOP_LOSS_PCT", 0.015) + self.scalp_stop_loss_pct = -abs(scalp_sl) # 음수로 통일 (ex: -0.015) + self.scalp_take_profit_pct = get_env_float("SCALP_TAKE_PROFIT_PCT", 0.015) + + # 스캘핑 낙폭 기준: 꼬리잡기 MIN_DROP_RATE와 독립 + # MIN_DROP_RATE(3%) 그대로 쓰면 1분봉 소형주에선 걸러지는 종목이 너무 많아짐 + # SCALP_MIN_DROP_RATE 기본 1.5%로 완화 → 타점 빈도 증가 + self.scalp_min_drop_rate = get_env_float("SCALP_MIN_DROP_RATE", 0.015) + + # 글로벌 손절/익절 (호환 유지 - check_sell_signals 등에서 참조) + self.stop_loss_pct = self.scalp_stop_loss_pct + self.take_profit_pct = self.scalp_take_profit_pct + self.max_stocks = get_env_int("MAX_STOCKS", 3) + self.min_drop_rate = self.scalp_min_drop_rate # 하위호환 + # 일일 회복률: 스캘핑에선 사용하지 않음 (RSI 과매도와 모순). 설정만 유지. + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5) + + # 스캘핑 전용 + # RSI 과매도: 이 값 이하 봉이 나오면 되돌림 후보 (기본 25) + self.rsi_oversold = get_env_float("SCALP_RSI_OVERSOLD", 25.0) + # RSI 과매수: 이 값 이상이면 고점 추격 방지 진입 금지 (기본 75) + self.rsi_overbought = get_env_float("SCALP_RSI_OVERBOUGHT", 75.0) + # 봉 단위 (분) + self.candle_tf = get_env_int("SCALP_CANDLE_TIMEFRAME", 1) + # 장 시작 후 대기 (분) + self.open_wait_min = get_env_int("SCALP_MARKET_OPEN_WAIT_MIN", 5) + + # 포지션 크기 + self.slot_money = get_env_float("SLOT_MONEY_DEFAULT", 300000) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + + # ATR 매도 배수 + self.atr_up_mult = get_env_float("SCALP_ATR_UP_MULT", 1.5) + self.atr_down_mult = get_env_float("SCALP_ATR_DOWN_MULT", 0.8) + + # 피뢰침/급등 필터 + self.high_chase_thr = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + self.max_daily_chg = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) + + # 거래량 배율 필터 + self.vol_multiplier = get_env_float("VOLUME_AVG_MULTIPLIER", 1.5) + + # ML + self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False) + self.ml_min_prob = get_env_float("ML_MIN_PROBABILITY", 0.55) + + # 최소 가격 + self.min_price = get_env_float("MIN_PRICE_TAIL", 1000.0) + + # ------------------------------------------------------------------ + # 장 상태 체크 + # ------------------------------------------------------------------ + + def check_market_status(self) -> bool: + """장 중 여부 + 장 시작 후 워밍업 대기 확인.""" + now = dt.now() + h, m = now.hour, now.minute + + # 장외 시간 + if not ((9 <= h < 15) or (h == 15 and m <= 30)): + return False + if get_env_bool("FORCE_MARKET_OPEN", False): + return True + + # 장 시작(9:00) 후 open_wait_min 분 대기 + market_start = now.replace(hour=9, minute=0, second=0, microsecond=0) + elapsed_min = (now - market_start).total_seconds() / 60 + if elapsed_min < self.open_wait_min: + logger.info("⏳ 장 시작 후 %.0f분 경과 (워밍업 대기: %d분)", + elapsed_min, self.open_wait_min) + return False + return True + + # ------------------------------------------------------------------ + # 구독 관리 (새 후보 추가/구독 종목 정리) + # ------------------------------------------------------------------ + + def _sync_subscriptions(self, candidates: list): + """ + target_candidates DB 목록과 WS 구독 목록을 동기화. + - 새 종목 → subscribe + 갭 보정 + - 유니버스에서 빠진 종목(보유 중 아닌 것) → unsubscribe + RAM 정리 + ※ 영구 구독 ETF(_permanent_ws_codes)는 절대 해제하지 않음 (시장 방향 필터용) + """ + if not self.ws_cache: + return + new_codes = {c.get("code") or c.get("stk_cd", "") for c in candidates if c} + new_codes.discard("") + # 현재 보유 종목은 매도 완료 전까지 반드시 유지 + new_codes |= set(self.holdings.keys()) + # 영구 구독 ETF는 유니버스와 무관하게 항상 유지 + new_codes |= getattr(self, '_permanent_ws_codes', set()) + + with self.ws_cache._sub_lock: + current_subs = set(self.ws_cache._subscribed) + + # ── 구독 해제: 유니버스에서 빠진 종목 ───────────────────────── + # 보유 중 종목은 매도 감시를 위해 구독 유지 + for code in sorted(current_subs - new_codes): + self.ws_cache.unsubscribe(code) + if self.candle_agg: + self.candle_agg.remove_code(code) + + # ── 신규 구독: 유니버스에 새로 들어온 종목 ───────────────────── + kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db) + use_kiwoom = bool(kw_key and kw_secret) + for code in sorted(new_codes - current_subs): + self.ws_cache.subscribe(code) + # 구독 즉시 갭 보정 (봉 버퍼 없는 신규 종목) — 키움 우선 + if not self.candle_agg: + continue + lim = get_env_int("SCALP_GAP_FILL_LIMIT", 120) + ok = False + for tf in self.candle_agg.timeframes: + df = None + if use_kiwoom: + try: + df = get_kiwoom_candles_df( + code, tf, kw_key, kw_secret, + is_mock=kw_mock, n=lim, + ) + except Exception as e: + logger.debug("키움 신규갭보정 실패 (%s %dM): %s", code, tf, e) + if (df is None or df.empty) and tf <= 3: + try: + df = self.client.get_minute_chart( + code, period=str(tf), limit=lim + ) + except Exception as e: + logger.debug("KIS 신규갭보정 실패 (%s %dM): %s", code, tf, e) + if df is not None and not df.empty: + self.candle_agg.fill_gap_from_rest(code, tf, df) + ok = True + # tf 간 딜레이 (차트 API, 토큰은 캐시 재사용) + time.sleep(random.uniform(0.2, 0.4)) + if ok: + logger.info("🔧 [신규갭보정] %s: 모든 tf 로드 완료", code) + else: + logger.warning("⚠️ [신규갭보정실패] %s: 데이터 없음 → 재시도 대기열 등록", code) + self._gap_retry_queue.add(code) + + # ------------------------------------------------------------------ + # 매수 신호: RSI 과매도 되돌림 + # ------------------------------------------------------------------ + + def check_buy_signal_scalp(self, code: str, name: str) -> Optional[dict]: + """엔진 check_buy_signal_live 사용 (백테스트와 동일 계산식) + 실매매 전용 필터(고점추격/급등/거래량/ML).""" + try: + if get_env_bool("FORCE_BUY_TEST", False): + return self._force_buy_test(code, name) + + if get_env_bool("USE_MARKET_REGIME_FILTER", False): + min_rsi = get_env_float("MARKET_REGIME_MIN_RSI", 48.0) + regime = self.db.get_market_regime(tf=60) + if not regime.get("is_bull") or regime.get("avg_rsi", 50) < min_rsi: + return None + if get_env_bool("USE_THEME_HEAT_FILTER", False): + heat_max = get_env_float("THEME_HEAT_RSI_MAX", 72.0) + meta = self.db.get_stock_meta(code) + if meta and meta.get("theme"): + momentum = self.db.get_theme_momentum(meta["theme"], tf=60) + if momentum.get("count", 0) >= 3 and momentum.get("avg_rsi3", 0) > heat_max: + return None + + if self.candle_agg: + candles = self.candle_agg.get_candles(code, self.candle_tf, n=50) + else: + candles = self.db.get_ws_candles(code, self.candle_tf, limit=50, confirmed_only=True) + if len(candles) < 5: + self._maybe_trigger_gap_retry(code, name, len(candles)) + return None + + candles = [self._norm_candle(c) for c in candles] + + today_yyyymmdd = dt.now().strftime("%Y%m%d") + last_exit_dt = None + if code in self.recently_sold: + try: + last_exit_dt = dt.fromtimestamp(self.recently_sold[code]) + if last_exit_dt.strftime("%Y%m%d") != today_yyyymmdd: + last_exit_dt = None + except Exception: + pass + try: + today_trades = self.db.get_trades_by_date(today_yyyymmdd) + daily_cnt = len([t for t in today_trades if t.get("code") == code and str(t.get("strategy", "")).startswith("SCALP")]) + except Exception: + daily_cnt = 0 + state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt} + + # 루프당 1회 설정된 params 사용 (후보마다 DB 열지 않음) + params = getattr(self, "_scan_engine_params", None) + if params is None: + _d = se.get_scalping_defaults_from_db() + params = { + **_d, + "rsi_oversold": self.rsi_oversold, + "rsi_overbought": self.rsi_overbought, + "sl_pct": abs(self.scalp_stop_loss_pct), + "tp_pct": self.scalp_take_profit_pct, + "drop_rate": self.scalp_min_drop_rate, + "vol_mult": self.vol_multiplier if self.vol_multiplier > 0 else 0, + } + + reject_reason, reject_msg, sig = se.check_buy_signal_live(candles, params, state) + if reject_reason: + logger.info("%s🔍 [%s] %s %s: %s%s", + LOG_YELLOW, reject_reason, name, code, reject_msg or "", LOG_RESET) + return None + if sig is None: + return None + + rsi3 = sig["rsi"] + latest = candles[-1] + curr_price = float(latest["close"]) + if curr_price < self.min_price: + return None + + current_price = curr_price + day_open = day_high = day_low = 0.0 + ws_d = self.ws_cache.get_price(code) if self.ws_cache else None + if ws_d: + current_price = abs(float(str(ws_d.get("stck_prpr", curr_price)).replace(",", ""))) or curr_price + day_open = abs(float(str(ws_d.get("stck_oprc", 0)).replace(",", ""))) + day_high = abs(float(str(ws_d.get("stck_hgpr", 0)).replace(",", ""))) + day_low = abs(float(str(ws_d.get("stck_lwpr", 0)).replace(",", ""))) + if day_open <= 0 or day_low <= 0: + pd_ = self.client.inquire_price(code) + if not pd_: + return None + current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", ""))) or current_price + day_open = abs(float(str(pd_.get("stck_oprc", 0)).replace(",", ""))) + day_high = abs(float(str(pd_.get("stck_hgpr", 0)).replace(",", ""))) + day_low = abs(float(str(pd_.get("stck_lwpr", 0)).replace(",", ""))) + if day_open <= 0 or day_low <= 0 or current_price <= 0: + return None + + drop_rate = (day_open - day_low) / day_open if day_open > 0 else 0 + if current_price >= day_high * self.high_chase_thr: + logger.info("%s🔍 [탈락-고점추격] %s %s: 현재가 %.0f ≥ 고가 %.0f × %.2f%s", + LOG_YELLOW, name, code, current_price, day_high, self.high_chase_thr, LOG_RESET) + return None + if day_low > 0: + daily_chg_pct = (day_high - day_low) / day_low * 100 + if daily_chg_pct > self.max_daily_chg: + logger.info("%s🔍 [탈락-피뢰침 급등주] %s %s: 일일 변동폭 %.1f%% > %.0f%%%s", + LOG_YELLOW, name, code, daily_chg_pct, self.max_daily_chg, LOG_RESET) + return None + + volumes = [float(c.get("volume", 0)) for c in candles] + avg_vol = sum(volumes[:-1]) / max(len(volumes) - 1, 1) + curr_vol = float(latest.get("volume", 0)) + if avg_vol > 0 and curr_vol < avg_vol * self.vol_multiplier: + logger.info("%s🔍 [탈락-거래량] %s %s: %.0f < 평균%.0f × %.1f%s", + LOG_YELLOW, name, code, curr_vol, avg_vol, self.vol_multiplier, LOG_RESET) + return None + + investor_trend = self.client.get_investor_trend(code, days=3) + entry_features = { + "rsi": rsi3, + "volume_ratio": curr_vol / avg_vol if avg_vol > 0 else 1.0, + "drop_rate": drop_rate, + "tail_length_pct": 0.0, + "ma5_gap_pct": None, + "ma20_gap_pct": None, + "foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0, + "institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0, + "market_hour": dt.now().hour, + } + + if self.use_ml_signal and getattr(self, "ml_predictor", None): + try: + ml_prob = self.ml_predictor.predict_win_probability({k: (v if v is not None else 0.0) for k, v in entry_features.items()}) + if ml_prob < self.ml_min_prob: + logger.info("%s🔍 [탈락-ML] %s %s: %.2f%% < %.0f%%%s", + LOG_YELLOW, name, code, ml_prob * 100, self.ml_min_prob * 100, LOG_RESET) + return None + except Exception: + pass + + score = 5.0 + (self.rsi_oversold - rsi3) / 5.0 + logger.info("%s🎯 [스캘핑 시그널] %s | 가격:%.0f | RSI3:%.1f | 낙폭:%.1f%% | 거래량:%.1fx%s", + LOG_CYAN, name, current_price, rsi3, drop_rate * 100, curr_vol / max(avg_vol, 1), LOG_RESET) + return { + "code": code, + "name": name, + "price": current_price, + "score": score, + "entry_features": entry_features, + } + except Exception as e: + logger.info("%s🔍 [탈락-예외] %s %s: %s%s", LOG_YELLOW, name, code, e, LOG_RESET) + return None + + def _norm_candle(self, c: dict) -> dict: + """캔들 dict 키 통일 (candle_time, open, high, low, close, volume).""" + ct = c.get("candle_time") or c.get("candle_time_str", "") + if len(ct) == 19 and " " in ct: + ct = ct.replace("-", "").replace(" ", "").replace(":", "")[:12] + return { + "candle_time": ct, + "open": float(c.get("open", 0)), + "high": float(c.get("high", 0)), + "low": float(c.get("low", 0)), + "close": float(c.get("close", 0)), + "volume": float(c.get("volume", 0)), + } + + def _maybe_trigger_gap_retry(self, code: str, name: str, n: int): + """봉 부족 시 재갭보정 트리거 (V1과 동일).""" + logger.info("%s🔍 [탈락-봉부족] %s %s: 확정봉 %d개 (최소 5개 필요)%s", LOG_YELLOW, name, code, n, LOG_RESET) + now_ts = time.time() + last_retry = getattr(self, "_gap_retry_ts", {}).get(code, 0) + if now_ts - last_retry <= get_env_int("SCALP_GAP_RETRY_SEC", 30): + return + self._gap_retry_ts[code] = now_ts + def _retry(c=code): + try: + tf = self.candle_tf + lim = get_env_int("SCALP_GAP_FILL_LIMIT", 100) + df = self.client.get_minute_chart(c, period=str(tf), limit=lim) + if df is not None and not df.empty and self.candle_agg: + self.candle_agg.fill_gap_from_rest(c, tf, df) + logger.info("🔧 [봉부족 재갭보정 완료] %s: %d행 로드", c, len(df)) + except Exception as ex: + logger.warning("⚠️ [봉부족 재갭보정 오류] %s: %s", c, ex) + threading.Thread(target=_retry, daemon=True, name=f"gap-retry-{code}").start() + + def _force_buy_test(self, code: str, name: str) -> Optional[dict]: + """FORCE_BUY_TEST 시 현재가만으로 매수 신호 (V1과 동일).""" + ws_d = self.ws_cache.get_price(code) if self.ws_cache else None + px = 0.0 + if ws_d: + px = abs(float(str(ws_d.get("stck_prpr", 0)))) + if px <= 0: + pd_ = self.client.inquire_price(code) + if pd_: + px = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", ""))) + if px > 0: + return {"code": code, "name": name, "price": px, "score": 5.0, "entry_features": {}} + return None + + # ------------------------------------------------------------------ + # 매수 실행 (ver1과 동일 구조) + # ------------------------------------------------------------------ + + def execute_buy(self, signal: dict) -> bool: + """매수 주문 실행 + DB 저장 + WS 구독.""" + code = signal["code"] + name = signal["name"] + price = signal["price"] + + if price <= 0: + return False + + # ── 포지션 크기 계산 (원화 손실 한도 역산) ──────────────────────────── + # MAX_LOSS_PER_TRADE_KRW(원) ÷ |SCALP_STOP_LOSS_PCT| = 최대 투자 가능 금액 + # 스캘핑 손절 1.5% 기준 예: 손실한도 20만원 → 투자상한 = 200,000 / 0.015 = 13,333,333원 + # SLOT_MONEY_DEFAULT 가 더 낮으면 그쪽 사용 (포지션 과집중 방지) + max_loss_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000) + # 스캘핑 전용 손절 비율 (SCALP_STOP_LOSS_PCT, 기본 1.5%) + scalp_sl_pct = abs(self.scalp_stop_loss_pct) # 양수로 처리 + if max_loss_krw > 0 and scalp_sl_pct > 0: + invest_limit = max_loss_krw / scalp_sl_pct + invest_amount = min(invest_limit, self.slot_money) + else: + invest_amount = self.slot_money + + qty = max(1, int(invest_amount / price)) + if qty <= 0: + return False + + # 스캘핑 전용 타이트한 손절/익절가 (SCALP_STOP/TAKE_PROFIT_PCT) + # 꼬리잡기의 STOP_LOSS_PCT(-4%), TAKE_PROFIT_PCT(+5%)와 완전 분리 + stop_price = price * (1 + self.scalp_stop_loss_pct) # ex: -1.5% + target_price = price * (1 + self.scalp_take_profit_pct) # ex: +1.5% + + ok = self.client.buy_order(code, qty, order_type="01") # 시장가 + if not ok: + return False + + now_str = dt.now().strftime("%Y-%m-%d %H:%M:%S") + holding = { + "buy_price": price, + "qty": qty, + "stop_price": stop_price, + "target_price": target_price, + "max_price": price, + "atr_entry": 0.0, + "buy_time": now_str, + "name": name, + "size_class": "", + } + self.holdings[code] = holding + + # DB 저장 (upsert_trade 는 trade_data dict 방식) + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SCALP_RSI_REVERSAL", + "avg_buy_price": price, + "current_price": price, + "stop_price": stop_price, + "target_price": target_price, + "max_price": price, + "atr_entry": 0.0, + "target_qty": qty, + "current_qty": qty, + "total_invested": price * qty, + "status": "HOLDING", + "buy_date": now_str, + "entry_features": signal["entry_features"], + }) + + # WS 구독 (이미 구독 중이면 무시됨) + if self.ws_cache: + self.ws_cache.subscribe(code) + + rsi_val = signal["entry_features"].get("rsi", 0) + msg = (f"🛒 **[스캘핑 매수]** {name}({code})\n" + f"매수가: {price:,.0f}원 × {qty}주 = {price*qty:,.0f}원\n" + f"손절: {stop_price:,.0f}원(-{abs(self.scalp_stop_loss_pct)*100:.1f}%) | " + f"익절: {target_price:,.0f}원(+{self.scalp_take_profit_pct*100:.1f}%)\n" + f"RSI3: {rsi_val:.1f}") + msg_mm(msg) + logger.info("✅ [매수체결] %s %s @ %d원 × %d주 | 손절-%.1f%% 익절+%.1f%%", + name, code, int(price), qty, + abs(self.scalp_stop_loss_pct) * 100, self.scalp_take_profit_pct * 100) + return True + + # ------------------------------------------------------------------ + # 매도 신호 체크 (ver2 check_sell_signals 와 동일 원리) + # ------------------------------------------------------------------ + + def check_sell_signals(self) -> List[dict]: + """ + 보유 종목 순회 → 손절/익절/ATR 조건 체크. + 현재가: WebSocket 캐시 우선, 없으면 REST fallback. + """ + signals = [] + now = dt.now() + + for code, holding in list(self.holdings.items()): + try: + name = holding.get("name", code) + buy_price = float(holding.get("buy_price", 0)) + qty = int(holding.get("qty", 0)) + stop_price = float(holding.get("stop_price", 0)) + target_price = float(holding.get("target_price", 0)) + max_price = float(holding.get("max_price", buy_price)) + + if qty <= 0 or buy_price <= 0: + continue + + # 매도 백오프 중이면 스킵 + backoff_until = self._sell_backoff.get(code, 0) + if time.time() < backoff_until: + continue + + # 현재가 조회: WS 캐시 → REST fallback + current_price = 0.0 + if self.ws_cache and self.ws_cache.is_active: + pd_ = self.ws_cache.get_price(code) + if pd_: + current_price = abs(float(str(pd_.get("stck_prpr", 0)))) + if current_price <= 0: + pd_ = self.client.inquire_price(code) + if pd_: + current_price = abs(float(str(pd_.get("stck_prpr", 0)).replace(",", ""))) + if current_price <= 0: + continue + + # max_price 갱신 + if current_price > max_price: + max_price = current_price + holding["max_price"] = max_price + self.db.upsert_trade({ + "code": code, + "name": holding.get("name", code), + "avg_buy_price": buy_price, + "current_price": current_price, + "max_price": max_price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": holding.get("buy_time", now.strftime("%Y-%m-%d %H:%M:%S")), + }) + + profit_pct = (current_price - buy_price) / buy_price # 가격 변화율 (수수료 전) + profit_val = (current_price - buy_price) * qty # 가격 손익 원화 (수수료 전) + + # ── 본절가(breakeven) 계산 ────────────────────────────── + # 왕복 수수료 + 세금 + 최소 마진을 합산한 최소 보장 라인 + # FEE_RATE_PCT : 위탁수수료 매수/매도 각각 (기본 0.015%) + # SELL_TAX_RATE_PCT: 증권거래세 매도 시만 (기본 0.18%) + # SCALP_MIN_PROFIT_PCT: 수수료 위 최소 순이익 마진 (기본 0.2%) + # breakeven = 매수가 × (1 + 수수료×2 + 세금 + 최소마진) + _fee = get_env_float("FEE_RATE_PCT", 0.015) / 100 + _tax = get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100 + _min_margin = get_env_float("SCALP_MIN_PROFIT_PCT", 0.2) / 100 + breakeven_pct = _fee * 2 + _tax + _min_margin + breakeven_price = buy_price * (1 + breakeven_pct) + + reason = None + + # [1] 손절 (% 기준) + if current_price <= stop_price: + reason = f"손절 ({profit_pct*100:.2f}%)" + + # [2] 금액 손실컷 (원화 기준) — 고가 종목에서 % 손절 이전에 원화 손실이 터지는 경우 대비 + # 예) 10만원짜리 20주 보유, 손절 -3% → 20만원 손실 → 설정값 초과 시 먼저 컷 + elif profit_val <= -get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000): + reason = f"금액손실컷 ({profit_val:,.0f}원)" + + # [3] 익절 (% 기준) + elif current_price >= target_price: + reason = f"익절 ({profit_pct*100:.2f}%)" + + # [4] 본절사수 — 한 번 의미 있게 올랐던 종목이 수수료 라인까지 내려오면 + # 트레일링 전에 본절+0.2%에서 먼저 청산 (마이너스 방지) + # 조건: 고점이 본절가 이상이었던 적 있음 + 현재가가 본절가로 회귀 + elif max_price >= breakeven_price and current_price <= breakeven_price: + net_pct = profit_pct - breakeven_pct + _min_margin + reason = f"본절사수 (순익≈{net_pct*100:+.2f}%)" + + # [5] ATR 트레일링 스탑 (고점 대비 하락) + # 발동 조건: 고점이 매수가 대비 본절가 이상 올라야 함 + # → 수수료 방어 라인을 넘긴 수익에서만 트레일링 작동 + elif max_price >= breakeven_price: + drop_from_high = (max_price - current_price) / max_price + if drop_from_high >= self.atr_down_mult * 0.01: + # 트레일링 발동이어도 현재가가 본절 이하면 본절사수로 전환 + # (위 [4]에서 먼저 걸리므로 여기는 본절 이상에서만 도달) + reason = f"트레일링스탑 고점대비-{drop_from_high*100:.1f}%" + + if reason: + signals.append({ + "code": code, + "name": name, + "current_price": current_price, + "qty": qty, + "buy_price": buy_price, + "profit_pct": profit_pct, + "reason": reason, + }) + except Exception as e: + logger.error("매도 신호 체크 오류(%s): %s", code, e) + + return signals + + # ------------------------------------------------------------------ + # 매도 실행 + # ------------------------------------------------------------------ + + def execute_sell(self, signal: dict): + """매도 주문 실행 + DB 업데이트 + WS 구독 해제.""" + code = signal["code"] + name = signal["name"] + current_price = signal["current_price"] + qty = signal["qty"] + buy_price = signal["buy_price"] + profit_pct = signal["profit_pct"] + reason = signal["reason"] + + # 메시지에 사용할 손익·보유시간 미리 계산 (holdings 삭제 전) + # ── 수수료 계산 (env/DB 에서 비율 로드) ───────────────────────── + # FEE_RATE_PCT : 위탁수수료 (매수/매도 각각, 기본 0.015%) + # SELL_TAX_RATE_PCT: 증권거래세 (매도 시만 부과, 기본 0.18%) + # 왕복 총비용 = buy_price×qty×fee + sell_price×qty×(fee + tax) + fee_rate = get_env_float("FEE_RATE_PCT", 0.015) / 100 + tax_rate = get_env_float("SELL_TAX_RATE_PCT", 0.18) / 100 + total_fee = (buy_price * qty * fee_rate + + current_price * qty * (fee_rate + tax_rate)) + gross_pnl = (current_price - buy_price) * qty # 수수료 제외 손익 + realized_pnl = gross_pnl - total_fee # 수수료 반영 순손익 + buy_time_str = (self.holdings.get(code) or {}).get("buy_time", + dt.now().strftime("%Y-%m-%d %H:%M:%S")) + try: + hold_min = int((dt.now() - dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S")).total_seconds() / 60) + except Exception: + hold_min = 0 + + ok = self.client.sell_market_order(code, qty) + if not ok: + msg_cd = self.client._last_sell_msg_cd or "" + msg1 = self.client._last_sell_msg1 or "" + # 영업일 아님/장 외 시간 오류 → 백오프 + if msg_cd in ("APBK0013", "APBK0962", "40910000"): + backoff = get_env_int("SELL_FAILURE_BACKOFF_SEC", 1800) + self._sell_backoff[code] = time.time() + backoff + logger.warning("⏳ [매도백오프] %s %s: %s → %d초 대기", + name, code, msg_cd, backoff) + # ── 잔고 없음: 계좌에 포지션이 없는데 로컬 DB·메모리에만 남은 경우 ── + # 모의투자 계정 초기화, 수동 취소 등으로 실제 잔고가 사라진 경우 + # 로컬 holdings·active_trades를 강제 정리해 무한 재시도 방지 + elif "잔고" in msg1 or "보유" in msg1 or "APBK3020" in msg_cd: + logger.warning("⚠️ [유령잔고 정리] %s %s: 브로커 잔고 없음 → 로컬 기록 강제 삭제", + name, code) + self.db.close_trade(code=code, sell_price=0, + sell_reason="잔고없음(강제정리)", + strategy="SCALP_RSI_REVERSAL") + self.holdings.pop(code, None) + return + + # DB: active_trades → trade_history 이동 (strategy 지정 → 스캘핑 row만 삭제) + # realized_pnl_override: 수수료·거래세 이미 차감된 순손익을 DB에 저장 + self.db.close_trade( + code=code, + sell_price=current_price, + sell_reason=reason, + strategy="SCALP_RSI_REVERSAL", + realized_pnl_override=realized_pnl, + ) + + if code in self.holdings: + del self.holdings[code] + + # WS 구독 해제 (재매수 대기 종목은 계속 구독) + self.recently_sold[code] = time.time() + + # 당일 확정 손익: 스캘핑(SCALP_) 전략만 필터 → 꼬리잡기 손익 혼합 방지 + try: + today_trades = self.db.get_trades_by_date(self.today_date) + day_pnl = sum( + (t.get("realized_pnl") or 0) + for t in today_trades + if str(t.get("strategy", "")).startswith("SCALP") + ) + except Exception: + day_pnl = 0 + + # 수수료 반영 순수익률 + net_pct = realized_pnl / (buy_price * qty) if buy_price * qty > 0 else 0 + emoji = "🔴" if realized_pnl < 0 else "🟢" + msg = (f"{emoji} **[스캘핑 매도]** {name}({code})\n" + f"{current_price:,.0f}원 × {qty:,}주 | {reason} | " + f"수익률 {net_pct*100:+.2f}% (실현 {realized_pnl:+,.0f}원 / 수수료 -{total_fee:,.0f}원)\n" + f"보유: {hold_min}분 | 보유 {len(self.holdings)}종목\n" + f"당일손익 {day_pnl:+,.0f}원") + msg_mm(msg) + logger.info("%s [매도체결] %s %s @ %d원 %+.2f%% (수수료 -%,.0f원) (%s)%s", + LOG_GREEN if realized_pnl >= 0 else LOG_RED, + name, code, int(current_price), net_pct * 100, total_fee, reason, LOG_RESET) + + # ------------------------------------------------------------------ + # 매인 루프 + # ------------------------------------------------------------------ + + def run(self): + """메인 루프 진입점.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """비동기 루프: 리포트 태스크 + 동기 매매 루프.""" + self._report_task = asyncio.create_task(self._report_scheduler()) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._sync_trading_loop) + + def _sync_trading_loop(self): + """동기 매매 루프.""" + logger.info("📈 스캘핑 매매 루프 시작") + last_cleanup_day = "" + + while True: + try: + self.reload_config() + now = dt.now() + today_str = now.strftime("%Y-%m-%d") + + # 날짜 변경 처리 + if today_str != self.today_date: + self.today_date = today_str + self.untradable_skip_set.clear() + self.morning_report_sent = False + self.closing_report_sent = False + logger.info("📅 날짜 변경: %s", today_str) + + # ws_candles 오래된 봉 정리 (1일 1회) + if today_str != last_cleanup_day: + keep = get_env_int("SCALP_CANDLE_KEEP_DAYS", 3) + self.db.cleanup_old_ws_candles(keep_days=keep) + last_cleanup_day = today_str + + # 장 외 시간: 보유 없으면 슬립 + if not self.check_market_status(): + time.sleep(30) + continue + + # ── 매도 우선 ───────────────────────────────────────── + sell_signals = self.check_sell_signals() + for sig in sell_signals: + self.execute_sell(sig) + + # ── 후보 종목 동기화 (새 종목 구독) ────────────────── + candidates = self.db.get_target_candidates() + self._sync_subscriptions(candidates) + + # ── 매수 체크 ───────────────────────────────────────── + active_cnt = len(self.holdings) + if candidates and active_cnt < self.max_stocks: + # 루프당 1회만 DB에서 기본값 로드 (후보마다 TradeDB 생성 방지 → 로그 스팸 제거) + _d = se.get_scalping_defaults_from_db() + self._scan_engine_params = { + **_d, + "rsi_oversold": self.rsi_oversold, + "rsi_overbought": self.rsi_overbought, + "sl_pct": abs(self.scalp_stop_loss_pct), + "tp_pct": self.scalp_take_profit_pct, + "drop_rate": self.scalp_min_drop_rate, + "vol_mult": self.vol_multiplier if self.vol_multiplier > 0 else 0, + } + logger.info("🔍 [매수체크] 후보 %d개 순회 (보유 %d/%d)", + len(candidates), active_cnt, self.max_stocks) + for c in candidates: + code = c.get("code") or c.get("stk_cd", "") + name = c.get("name") or c.get("stk_nm", code) + if not code or code in self.holdings: + continue + if code in self.untradable_skip_set: + continue + + # 재진입 쿨다운 (스캘핑 전용 SCALP_COOLDOWN_SEC → 없으면 REENTRY_COOLDOWN_SEC 폴백) + reentry_cd = get_env_int("SCALP_COOLDOWN_SEC", None) or get_env_int("REENTRY_COOLDOWN_SEC", 300) + elapsed = time.time() - self.recently_sold.get(code, 0) + if elapsed < reentry_cd: + remaining = int(reentry_cd - elapsed) + logger.info("⏳ [재진입차단] %s %s — %d초 남음", + name, code, remaining) + continue + + signal = self.check_buy_signal_scalp(code, name) + if signal: + ok = self.execute_buy(signal) + if ok: + time.sleep(random.uniform(1, 2)) + break + time.sleep(random.uniform(0.3, 0.8)) + continue + time.sleep(random.uniform(0.2, 0.5)) + + # 스캘핑 루프: 짧은 대기 (1분봉 집계 시 1~2초면 충분) + time.sleep(random.uniform(1, 2)) + + except KeyboardInterrupt: + logger.info("⏹ 봇 종료 (KeyboardInterrupt)") + if self._report_task: + self._report_task.cancel() + break + except Exception as e: + logger.error("❌ 루프 에러: %s", e) + time.sleep(5) + + # ------------------------------------------------------------------ + # 리포트 스케줄러 + # ------------------------------------------------------------------ + + async def _report_scheduler(self): + """장 시작(9:05) / 마감(15:35) 리포트 전송.""" + while True: + try: + now = dt.now() + if (now.hour == 9 and now.minute == 5 and not self.morning_report_sent): + self._send_report("morning") + self.morning_report_sent = True + elif (now.hour == 15 and now.minute == 35 and not self.closing_report_sent): + self._send_report("closing") + self.closing_report_sent = True + except Exception as e: + logger.error("리포트 스케줄러 오류: %s", e) + await asyncio.sleep(30) + + def _send_report(self, report_type: str): + """간단한 보유 현황 리포트 전송.""" + try: + now_str = dt.now().strftime("%Y-%m-%d %H:%M") + title = "🌅 장 시작 보유현황" if report_type == "morning" else "📊 마감 스캘핑 리포트" + lines = [f"**{title}** ({now_str})\n"] + + if self.holdings: + for code, h in self.holdings.items(): + price_data = self.client.inquire_price(code) + curr = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) \ + if price_data else h.get("buy_price", 0) + pnl_pct = (curr - h["buy_price"]) / h["buy_price"] * 100 if h["buy_price"] > 0 else 0 + lines.append(f"- {h['name']}({code}): {curr:,.0f}원 {pnl_pct:+.1f}%") + time.sleep(0.2) + else: + lines.append("- 보유 종목 없음") + + # 오늘 매매 결과 — 스캘핑(SCALP_) 전략만 집계 + today_yyyymmdd = dt.now().strftime("%Y%m%d") + all_today = self.db.get_trades_by_date(today_yyyymmdd) + history = [t for t in all_today + if str(t.get("strategy", "")).startswith("SCALP")] + if history: + wins = [t for t in history if (t.get("profit_rate") or 0) >= 0] + total_pnl = sum((t.get("realized_pnl") or 0) for t in history) + lines.append(f"\n오늘 매매: {len(history)}건 | 승{len(wins)}/패{len(history)-len(wins)} | " + f"손익 {total_pnl:+,.0f}원") + + msg_mm("\n".join(lines)) + except Exception as e: + logger.error("리포트 전송 실패: %s", e) + + +# ══════════════════════════════════════════════════════════════════════ +if __name__ == "__main__": + bot = ScalpingBotV2() + logger.info("🚀 ScalpingBotV2 (엔진 기반) 시작") + bot.run() diff --git a/kis_short_ver1.py b/kis_short_ver1.py index 665584a..f31dca2 100644 --- a/kis_short_ver1.py +++ b/kis_short_ver1.py @@ -25,7 +25,7 @@ from typing import List, Dict, Optional import pandas as pd import requests -from database import TradeDB +from database import TradeDB, ENV_CONFIG_KEYS # 로깅 설정 logging.basicConfig( @@ -203,6 +203,26 @@ def _save_kis_token_cache(access_token, access_token_token_expired, mock): logger.warning("한투 토큰 캐시 저장 실패: %s", e) +def _invalidate_kis_token_cache(mock): + """토큰 만료(EGW00123) 시 캐시 파일 삭제 → 다음 _auth()에서 새 토큰 발급.""" + path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL + try: + if path.exists(): + path.unlink() + logger.info("한투 토큰 캐시 삭제 (만료 감지): %s", path) + except Exception as e: + logger.warning("한투 토큰 캐시 삭제 실패(%s): %s", path, e) + + +def _is_token_expired_response(j): + """응답이 '기간이 만료된 token' 오류(EGW00123)인지 여부.""" + if not j or not isinstance(j, dict): + return False + msg_cd = j.get("msg_cd") or "" + msg1 = str(j.get("msg1", "")) + return msg_cd == "EGW00123" or "만료된 token" in msg1 or "만료" in msg1 + + class KISClient: """한국투자증권 Open API 클라이언트""" def __init__(self, mock=None): @@ -263,6 +283,12 @@ class KISClient: self.base_url = "https://openapi.koreainvestment.com:9443" self.access_token = None + # 매수 주문 실패 시 사유 저장 (매매불가 종목 당일 제외용) + self._last_order_msg_cd = None + self._last_order_msg1 = None + # 현재가 API 캐시 (레이트리밋·지연 완화용) + # {종목코드: (timestamp, output_dict)} + self._price_cache = {} logger.info("한투 API 연결: 모의=%s → %s", self.mock, self.base_url) self._auth() @@ -349,49 +375,64 @@ class KISClient: headers["hashkey"] = hashkey return headers - def _get(self, path, tr_id, params, max_retries=3, tr_cont=None): + def _get(self, path, tr_id, params, max_retries=5, tr_cont=None): """ - GET 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 시간 증가 재시도 + GET 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 후 재시도 (200/500 공통). + EGW00123(기간이 만료된 token) 시 캐시 삭제 후 새 토큰 발급·1회 재시도. - 한투 API 제한: 초당 20개 (실제로는 더 엄격, 모의투자는 초당 2~3회 권장) - - EGW00201 감지 시: 5초 + (attempt * 1초) 대기 후 재시도 - 기본 호출 간격: 0.5초 이상 권장 """ url = f"{self.base_url}{path}" - headers = self._headers(tr_id) if tr_cont: - headers["tr_cont"] = tr_cont # 연속 조회 시 다음 페이지 요청 (한투: Response Header tr_cont=M 이면 Request Header tr_cont=N) + headers_extra = {"tr_cont": tr_cont} + else: + headers_extra = {} logger.debug(f"[API호출] GET {path} TR_ID={tr_id} params={params} tr_cont={tr_cont}") - time.sleep(0.5) - + + token_refreshed = False for attempt in range(max_retries): try: + headers = self._headers(tr_id) + for k, v in headers_extra.items(): + headers[k] = v r = requests.get(url, headers=headers, params=params, timeout=15) - + # HTTP 429 (Too Many Requests) if r.status_code == 429: - wait_time = 1 + (attempt * 1) # 5초, 6초, 7초... + wait_time = 1 + (attempt * 1) logger.warning( f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " f"({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(초당 거래건수 초과)이면 대기 후 재시도 + 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": logger.debug(f"[API성공] GET {path} TR_ID={tr_id} status=200 rt_cd=0") return r - # EGW00201: 초당 거래건수 초과 - elif j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): - wait_time = 1 + (attempt * 1) # 5초, 6초, 7초... (키움 봇 방식) + 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} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 " - f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + f"⏳ API 과부하 (EGW00201) GET {path} TR_ID={tr_id} -> {wait_time:.1f}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) msg1={j.get('msg1', '')}" ) time.sleep(wait_time) continue + # EGW00123: 기간이 만료된 token → 캐시 삭제 후 새 토큰 발급, 1회만 재시도 + if _is_token_expired_response(j) and not token_refreshed: + token_refreshed = True + _invalidate_kis_token_cache(self.mock) + self._auth() + logger.info("한투 토큰 만료(EGW00123) 감지 → 캐시 삭제 후 재발급, GET 재시도") + time.sleep(0.5) + continue # HTTP 200이 아니거나 rt_cd != "0"인 경우 try: body_preview = (r.text or "")[:500] @@ -413,60 +454,57 @@ class KISClient: def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3): """ - POST 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 시간 증가 재시도 + POST 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 후 재시도. + EGW00123(기간이 만료된 token) 시 캐시 삭제 후 새 토큰 발급·1회 재시도. - 한투 API 제한: 초당 20개 (실제로는 더 엄격) - - EGW00201 감지 시: 5초 + (attempt * 1초) 대기 후 재시도 """ url = f"{self.base_url}{path}" - hashkey = None - - # API 호출 정보 디버그 로그 (민감 정보는 일부만) body_preview = str(body)[:200] if body else "{}" logger.debug(f"[API호출] POST {path} TR_ID={tr_id} body={body_preview}...") - - # 기본 안전 대기 (서버 부하 완화) time.sleep(0.5) - - # 해시키 발급 (선택적이지만 보안 강화) - if use_hashkey: - hashkey = self._get_hashkey(body) - if not hashkey: - logger.debug("해시키 발급 실패, 해시키 없이 진행") - + + token_refreshed = False for attempt in range(max_retries): try: + hashkey = self._get_hashkey(body) if use_hashkey else None + if use_hashkey and not hashkey: + logger.debug("해시키 발급 실패, 해시키 없이 진행") r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15) - + # HTTP 429 (Too Many Requests) if r.status_code == 429: - wait_time = 5 + (attempt * 1) # 5초, 6초, 7초... + wait_time = 5 + (attempt * 1) logger.warning( f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " f"({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": logger.debug(f"[API성공] POST {path} TR_ID={tr_id} status=200 rt_cd=0") return r - # EGW00201: 초당 거래건수 초과 - elif j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): - wait_time = 5 + (attempt * 1) # 5초, 6초, 7초... (키움 봇 방식) + if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = 5 + (attempt * 1) logger.warning( f"⏳ API 과부하 (EGW00201) POST {path} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 " f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" ) time.sleep(wait_time) - if use_hashkey: - hashkey = self._get_hashkey(body) continue - # HTTP 200이 아니거나 rt_cd != "0"인 경우 + # EGW00123: 기간이 만료된 token → 캐시 삭제 후 새 토큰 발급, 1회만 재시도 + if _is_token_expired_response(j) and not token_refreshed: + token_refreshed = True + _invalidate_kis_token_cache(self.mock) + self._auth() + logger.info("한투 토큰 만료(EGW00123) 감지 → 캐시 삭제 후 재발급, POST 재시도") + time.sleep(0.5) + continue try: body_preview = (r.text or "")[:500] except Exception: @@ -491,6 +529,17 @@ class KISClient: output: stck_prpr(현재가) ✅, stck_oprc(시가), stck_hgpr(고가), stck_lwpr(저가) 등 당일 OHLC 포함. 실패 시 오류코드(rt_cd, msg_cd, msg1) 로깅. """ + # 짧은 TTL 캐시로 동일 종목 반복 호출 시 레이트리밋·지연 완화 + cache_ttl = get_env_float("KIS_PRICE_CACHE_TTL_SEC", 1.0) + if cache_ttl > 0: + now_ts = time.time() + cached = self._price_cache.get(stock_code) + if cached: + ts, output = cached + if now_ts - ts <= cache_ttl: + logger.debug(f"[현재가API-캐시히트] code={stock_code} ttl={cache_ttl}s") + return output + path = "/uapi/domestic-stock/v1/quotations/inquire-price" tr_id = "FHKST01010100" params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code} @@ -519,7 +568,13 @@ class KISClient: f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" ) return None - return j.get("output") + output = j.get("output") + if cache_ttl > 0 and output is not None: + try: + self._price_cache[stock_code] = (time.time(), output) + except Exception: + pass + return output def inquire_multprice(self, stock_codes: List[str], max_per_call: int = 20): """ @@ -743,7 +798,52 @@ class KISClient: except Exception as e: logger.error(f"주문 내역 조회 실패: {e}") return None - + + def get_execution_by_odno(self, odno, code=None, wait_sec=2): + """ + 주문번호(ODNO)로 당일 체결 내역 조회. 시장가 매수 후 실제 체결가/체결수량 확인용. + [v1_국내주식-012] inquire-daily-ccld 응답 output2에서 해당 주문 찾아 체결정보 반환. + + Args: + odno: 주문번호 (buy_order 성공 시 반환값) + code: 종목코드 (선택, 일치 행 필터용) + wait_sec: 조회 전 대기 초 (체결 반영 지연 고려, 기본 2초) + + Returns: + 성공 시 {"filled_qty": int, "avg_price": float}, 미체결/조회실패 시 None + """ + if not odno: + return None + time.sleep(max(0, wait_sec)) + try: + j = self.get_order_history() + if not j: + return None + out2 = j.get("output2", []) + if isinstance(out2, dict): + out2 = [out2] + for row in out2: + row_odno = str(row.get("ODNO") or row.get("ord_no") or "").strip() + row_pdno = str(row.get("PDNO") or row.get("pdno") or "").strip() + if row_odno != str(odno).strip(): + continue + if code and row_pdno and row_pdno != str(code).strip(): + continue + # 총체결수량 / 체결평균가 (한투 문서 필드명 다양하므로 후보 나열) + filled = row.get("tot_ccld_qty") or row.get("TOT_CCLD_QTY") or row.get("ccld_qty") or row.get("ord_qty") or row.get("ORD_QTY") + avg_pr = row.get("avg_prvs") or row.get("AVG_PRVS") or row.get("rjct_avg_prvs") or row.get("RJCT_AVG_PRVS") or row.get("ord_unpr") or row.get("ORD_UNPR") + if filled is not None and avg_pr is not None: + qty = int(float(str(filled).replace(",", ""))) + price = float(str(avg_pr).replace(",", "")) + if qty > 0 and price > 0: + logger.debug(f"[체결조회] ODNO={odno} -> 체결수량={qty}, 체결평균가={price:,.0f}") + return {"filled_qty": qty, "avg_price": price} + logger.debug(f"[체결조회] ODNO={odno} 해당 주문 체결 내역 없음 (output2 건수={len(out2)})") + return None + except Exception as e: + logger.warning(f"주문번호 체결 조회 예외 ODNO={odno}: {e}") + return None + def get_volume_surge_stocks(self, market="J", min_volume_rate="50", limit=50): """거래량 급증 종목 조회 [v1_국내주식-023]""" try: @@ -952,22 +1052,39 @@ class KISClient: return False j = r.json() if j.get("rt_cd") == "0": + self._last_order_msg_cd = None + self._last_order_msg1 = None ord_no = j.get("output", {}).get("ODNO", "") logger.info(f"✅ 매수 주문 성공: {code} {qty}주 (주문번호: {ord_no})") - return True + # 체결 확인용으로 주문번호 반환 (실패 시 False) + return ord_no if ord_no else True else: + # 매매불가 등 실패 시 bot에서 당일 제외용으로 구분할 수 있도록 저장 + self._last_order_msg_cd = j.get("msg_cd", "") + self._last_order_msg1 = str(j.get("msg1", "") or "") logger.error( f"[매수주문] 실패 code={code} path={path} TR_ID={tr_id} " - f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + f"rt_cd={j.get('rt_cd')} msg_cd={self._last_order_msg_cd} msg1={self._last_order_msg1}" ) return False except Exception as e: + self._last_order_msg_cd = None + self._last_order_msg1 = None logger.error(f"매수 주문 예외({code}): {e}") return False def buy_market_order(self, code, qty): - """시장가 매수 주문 (간편 메서드)""" - return self.buy_order(code, qty, price=0, order_type="01") + """ + 시장가 매수 주문. + - 모의투자: FOK/IOC 미지원이므로 무조건 "01" 일반 시장가. + - 실전: USE_MARKET_IOC=true면 "13" 시장가 IOC, false면 "01" 일반 시장가. + """ + if self.mock: + order_type = "01" + else: + use_ioc = get_env_from_db("USE_MARKET_IOC", "true").strip().lower() in ("true", "1", "yes") + order_type = "13" if use_ioc else "01" + return self.buy_order(code, qty, price=0, order_type=order_type) def sell_order(self, code, qty, price=0, order_type="01"): """ @@ -1063,15 +1180,19 @@ class KISClient: rows = [] for item in data: try: + # 정렬용: 영업일자+체결시각(있으면) → 마지막 봉이 실제 최신봉이 되도록 + date_str = str(item.get("stck_bsop_date", "") or "") + time_str = str(item.get("stck_cntg_hour", "") or item.get("cntg_hour", "") or "000000") + sort_key = date_str + time_str rows.append({ - "time": item.get("stck_bsop_date", ""), + "time": sort_key, "open": abs(float(item.get("stck_oprc", 0))), "high": abs(float(item.get("stck_hgpr", 0))), "low": abs(float(item.get("stck_lwpr", 0))), "close": abs(float(item.get("stck_clpr", 0))), "volume": int(item.get("acml_vol", 0)), }) - except: + except Exception: continue if not rows: @@ -1397,6 +1518,23 @@ class MattermostBot: return False +def _save_ai_recommendations_from_text(db_instance, analysis_text: str): + """AI 분석문에서 'KEY=값' 추천 줄만 추출해 DB에 저장 (!적용 시 사용).""" + if not analysis_text or not db_instance: + return + valid_keys = set(ENV_CONFIG_KEYS) + lines = [] + 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: + lines.append(f"{m.group(1)}={m.group(2).strip()}") + if lines: + db_instance.set_last_ai_recommendations("\n".join(lines)) + + # ============================================================ # 단타 트레이딩 봇 # ============================================================ @@ -1421,55 +1559,25 @@ class ShortTradingBot: except Exception as e: logger.warning(f"⚠️ ML 예측 초기화 실패: {e}") - # 전략 파라미터 (DB·env 연동) - # ============================================================ - # [손절/익절 설정] - 대형주 기준 현실적인 값 - # ============================================================ - # 손절 라인: -4% (주가 기준, 대형주는 노이즈 감안해 넉넉히) - self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04) # -4% 손절 (대형주 기준) - self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05) - self.max_stocks = get_env_int("MAX_STOCKS", 3) - # 개미털기 필터: 낙폭 3% 이상 + 회복률 50% 이상 통과 (후보가 너무 적으면 MIN_DROP_RATE=0.02, MIN_RECOVERY_RATIO_SHORT=0.32 등 완화) - self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03) - self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5) - - # ATR 기반 손절/익절 배수 (변동성 기반 동적 손절가/목표가) - self.stop_atr_multiplier = get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5) - self.target_atr_multiplier = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 8.0) - - # 24시간 보유 전략 (N자 패턴 등 다음날 상승 가능성 있는 종목) - self.min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) # 최소 보유 시간 (기본 24시간) - - # ============================================================ - # [리스크 관리 설정] - 변동성 역가중 (Volatility Inverse Weighting) - # ============================================================ - self.risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.01) # 1% (계좌 기준) - self.kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25) # 쿼터 켈리 (0.25) - self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.15) # 종목당 최대 15% - self.min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 50000) # 최소 5만원 - - # RiskManager 초기화 (변동성 역가중 + 쿼터 켈리 매수 금액 계산) + # RiskManager 초기화 (뼈대만 생성 → reload_config에서 DB 값으로 실시간 갱신) self.risk_mgr = None if RISK_MANAGER_AVAILABLE: - # 켈리 공식 사용 여부 (기본 ON - 쿼터 켈리 0.25 적용) - use_kelly = get_env_bool("USE_KELLY_FORMULA", True) self.risk_mgr = RiskManager( - risk_pct_per_trade=self.risk_pct_per_trade, - max_position_pct=self.max_position_pct, - min_position_amount=self.min_position_amount, - use_kelly=use_kelly, - kelly_multiplier=self.kelly_multiplier, # 쿼터 켈리 0.25 + risk_pct_per_trade=get_env_float("RISK_PCT_PER_TRADE", 0.01), + max_position_pct=get_env_float("MAX_POSITION_PCT", 0.15), + min_position_amount=get_env_int("MIN_POSITION_AMOUNT", 50000), + use_kelly=get_env_bool("USE_KELLY_FORMULA", True), + kelly_multiplier=get_env_float("KELLY_MULTIPLIER", 0.25), + slot_base_amount_cap=get_env_int("SLOT_BASE_AMOUNT_CAP", 0), ) - kelly_status = f"켈리{'ON' if use_kelly else 'OFF'}(배율={self.kelly_multiplier})" - logger.info(f"✅ RiskManager 활성화: 변동성 역가중 + {kelly_status}") + logger.info("✅ RiskManager 뼈대 생성 완료") else: logger.warning("⚠️ RiskManager 미사용: 고정 슬롯 금액 방식으로 폴백") - - # ML 신호 필터링 설정 - self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False) - self.ml_min_probability = get_env_float("ML_MIN_PROBABILITY", 0.57) - - # 리포트 플래그 + + # ★ [실시간 리로드] DB에서 최신 설정값을 불러와 봇·RiskManager에 반영 (재시작 없이 적용) + self.reload_config() + + # 리포트 플래그 (reload_config 이후 유지) self.morning_report_sent = False self.closing_report_sent = False self.final_report_sent = False @@ -1485,6 +1593,8 @@ class ShortTradingBot: # DB에서 활성 트레이드 로드 (단타만: SHORT_% - 늘림목과 섞이지 않도록) self.holdings = {} + # 당일 매매불가로 확인된 종목 (같은 종목 반복 주문 방지 → 다음 후보로 넘어감) + self.untradable_skip_set = set() active_trades = self.db.get_active_trades(strategy_prefix="SHORT") for code, trade in active_trades.items(): self.holdings[code] = { @@ -1494,15 +1604,77 @@ class ShortTradingBot: "name": trade.get("name", code), } - # 초기 자산 조회 - self._update_assets() + # ★ 계좌 잔고 API와 동기화 (폰/타 앱으로 산 종목 반영) + balance = self.client.get_account_balance() + if balance is not None: + self._sync_holdings_from_balance(balance) + self._update_assets(balance=balance) # 이미 받은 잔고로 자산 갱신 (API 1회만) + else: + self._update_assets() # 비동기 태스크 관리 self._universe_task = None self._report_task = None self._asset_task = None self.is_first_run = True - + + def reload_config(self): + """[실시간 리로드] DB(env) 설정을 봇에 반영. 메인 루프마다 호출 시 재시작 없이 적용.""" + # [손절/익절 설정] + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05) + self.max_stocks = get_env_int("MAX_STOCKS", 3) + self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03) + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5) + + self.stop_atr_multiplier = get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5) + self.target_atr_multiplier = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 8.0) + self.min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) + + # [리스크 관리 설정] + self.risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.01) + self.kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25) + self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.15) + self.min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 50000) + use_kelly = get_env_bool("USE_KELLY_FORMULA", True) + + if self.risk_mgr is not None: + self.risk_mgr.risk_pct = self.risk_pct_per_trade + self.risk_mgr.max_pos_pct = self.max_position_pct + self.risk_mgr.min_amount = self.min_position_amount + self.risk_mgr.use_kelly = use_kelly + self.risk_mgr.kelly_mult = self.kelly_multiplier + + # ML 신호 필터링 + self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False) + self.ml_min_probability = get_env_float("ML_MIN_PROBABILITY", 0.57) + + # 자산 기준 (리포트용) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + + def _get_effective_slot_cap(self) -> float: + """ + 슬롯당 실투입 상한 금액 계산. + - env: SLOT_BASE_AMOUNT_CAP (직접 지정) + - env: MAX_LOSS_PER_TRADE_KRW + STOP_LOSS_PCT 로부터 역산 + (MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT|) + 둘 다 설정되어 있으면 더 작은 값 사용. + 설정이 없으면 0 리턴(제한 없음). + """ + slot_cap_env = get_env_float("SLOT_BASE_AMOUNT_CAP", 0.0) + max_loss_krw = get_env_float("MAX_LOSS_PER_TRADE_KRW", 0.0) + stop_abs = abs(self.stop_loss_pct) if self.stop_loss_pct != 0 else 0.0 + derived_cap = 0.0 + if max_loss_krw > 0 and stop_abs > 0: + try: + derived_cap = max_loss_krw / stop_abs + except Exception: + derived_cap = 0.0 + candidates = [c for c in (slot_cap_env, derived_cap) if c and c > 0] + if not candidates: + return 0.0 + return float(min(candidates)) + def _seconds_until_next_5min(self): """다음 5분 정각까지 남은 초 계산""" now = dt.now() @@ -1524,11 +1696,14 @@ class ShortTradingBot: logger.info("📡 [복합 스캔] 개미털기 우선 + 4가지 보너스 소스") try: - # 매수 가능 금액 계산 + # 매수 가능 금액 계산 (수치: env/DB) if self.max_stocks > 0: slot_money = int(self.current_cash * 0.9 / self.max_stocks) else: - slot_money = 100000 + slot_money = int(get_env_float("SLOT_MONEY_DEFAULT", 100000.0)) + # 슬롯 캡(손절 기반 상한) 함께 고려 → 유니버스 단계에서부터 가격 상한 필터 + effective_slot_cap = self._get_effective_slot_cap() + price_cap = effective_slot_cap if effective_slot_cap > 0 else slot_money all_candidates = {} # {code: {name, price, base_score, bonus_score, total_score}} @@ -1540,19 +1715,87 @@ class ShortTradingBot: # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ try: ant_shaking = self.scan_ant_shaking_candidates(max_candidates=50) - logger.info(f" ✅ [개미털기] {len(ant_shaking)}개 수집 (강도 원본 유지)") - for item in ant_shaking: - code = item['code'] + logger.info(f" ✅ [개미털기] {len(ant_shaking)}개 수집") + # 키움과 동일: 강도 = 낙폭률×100. 단, 분봉 대신 단일 시세(inquire_price) 기반으로 당일 전체 낙폭 계산. + drop_delay = max(0.3, get_env_float("API_DELAY_DROP_SCORE_SEC", 0.5)) + max_drop_score_calls = min(get_env_int("SCAN_ANT_DROP_SCORE_MAX", 30), len(ant_shaking)) + scan_min_price = get_env_float("SCAN_MIN_PRICE", get_env_float("MIN_PRICE_TAIL", 1000.0)) + scan_min_drop = get_env_float("SCAN_ANT_MIN_DROP_RATE", 0.03) + scan_max_change = get_env_float("SCAN_MAX_DAILY_CHANGE_PCT", get_env_float("MAX_DAILY_CHANGE_PCT", 20.0)) + real_drop_count = 0 + for idx, item in enumerate(ant_shaking): + if idx >= max_drop_score_calls: + break + code = item.get('code') + if not code: + continue + base = float(item.get('score', 0)) if item.get('score', 0) else 0.0 + drop_rate = item.get('drop_rate', 0) + recovery = item.get('recovery', 0) + price = item.get('price', 0) or 0 + # 이미 점수가 있는 경우(외부 계산 값)는 그대로 두고, 없는 경우에만 한투 시세로 강도 재계산 + if base <= 0: + try: + time.sleep(drop_delay) + price_data = self.client.inquire_price(code) + if not price_data: + continue + day_open = abs(float(str(price_data.get("stck_oprc", 0)).replace(",", "").strip() or 0)) + day_high = abs(float(str(price_data.get("stck_hgpr", 0)).replace(",", "").strip() or 0)) + day_low = abs(float(str(price_data.get("stck_lwpr", 0)).replace(",", "").strip() or 0)) + day_close = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", "").strip() or 0)) + if not price and day_close > 0: + price = day_close + # 가격·급등 필터 (키움 개미털기 스캔과 유사하게 동전주/상한가 근처 제외) + price_for_filter = price or day_close + if price_for_filter > 0 and price_for_filter < scan_min_price: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-가격(스캔)] {item.get('name', code)} {code}: " + f"현재가 {price_for_filter:,.0f}원 < 최소 {scan_min_price:,.0f}원{LOG_RESET}" + ) + continue + if day_open <= 0 or day_low <= 0: + continue + drop_rate = (day_open - day_low) / day_open + day_range = day_high - day_low + recovery = (day_close - day_low) / day_range if day_range > 0 else 0 + drop_rate = max(0.0, min(1.0, drop_rate)) + recovery = max(0.0, min(1.0, recovery)) + change_pct = (day_close - day_open) / day_open * 100.0 if day_open > 0 else 0.0 + if change_pct > scan_max_change: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-급등(스캔)] {item.get('name', code)} {code}: " + f"일간 등락 {change_pct:.1f}% > 허용 {scan_max_change:.1f}%{LOG_RESET}" + ) + continue + if drop_rate >= scan_min_drop: + base = drop_rate * 100.0 + real_drop_count += 1 + else: + # 낙폭이 기준 미만이면 개미털기 강도는 0으로 두고, 다른 랭킹 보너스만 사용 + base = 0.0 + except Exception as e: + logger.debug(f"개미털기 강도 조회 실패({code}): {e}") + continue + # ant_shaking 쪽도 슬롯 캡 기반 가격 상한 적용 (day_close 기준) + if price_cap > 0 and price and price > price_cap: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-가격(슬롯캡)] {item.get('name', code)} {code}: " + f"현재가 {price:,.0f}원 > 슬롯캡 {price_cap:,.0f}원{LOG_RESET}" + ) + continue all_candidates[code] = { 'code': code, - 'name': item['name'], - 'price': item['price'], - 'base_score': item['score'], # 개미털기 원본 점수 (100%) - 'bonus_score': 0.0, # 보너스 점수 (추가) + 'name': item.get('name', code), + 'price': price, + 'base_score': base, + 'bonus_score': 0.0, 'from_ant': True, - 'drop_rate': item.get('drop_rate', 0), - 'recovery': item.get('recovery', 0), + 'drop_rate': max(0.0, min(1.0, drop_rate)), + 'recovery': max(0.0, min(1.0, recovery)), } + if max_drop_score_calls > 0: + logger.info(f" 📊 [개미털기 강도] 낙폭 실계산 {real_drop_count}명, fallback 없음 (기준 미달/조회 실패는 강도 0 처리)") except Exception as e: logger.warning(f" ⚠️ [개미털기] 수집 실패: {e}") @@ -1575,7 +1818,7 @@ class ShortTradingBot: price_data = self.client.inquire_price(code) if price_data: current_price = abs(float(price_data.get("stck_prpr", 0))) - if current_price > 0 and current_price <= slot_money: + if current_price > 0 and (price_cap <= 0 or current_price <= price_cap): all_candidates[code] = { 'code': code, 'name': item.get('stk_nm', code), @@ -1605,7 +1848,7 @@ class ShortTradingBot: price_data = self.client.inquire_price(code) if price_data: current_price = abs(float(price_data.get("stck_prpr", 0))) - if current_price > 0 and current_price <= slot_money: + if current_price > 0 and (price_cap <= 0 or current_price <= price_cap): all_candidates[code] = { 'code': code, 'name': item.get('stk_nm', code), @@ -1617,6 +1860,7 @@ class ShortTradingBot: except Exception as e: logger.warning(f" ⚠️ [등락률순위] 수집 실패: {e}") + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 4. 거래대금순위 - 보너스 +0.2 # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ @@ -1652,7 +1896,7 @@ class ShortTradingBot: price_data = self.client.inquire_price(code) if price_data: current_price = abs(float(price_data.get("stck_prpr", 0) or 0)) - if current_price > 0 and (not slot_money or current_price <= slot_money): + if current_price > 0 and (price_cap <= 0 or current_price <= price_cap): all_candidates[code] = { "code": code, "name": item.get("stk_nm", code), @@ -1668,11 +1912,13 @@ class ShortTradingBot: # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # 5. 외국인/기관 순매수 - 보너스 +0.3 (투자자 동향 기반) # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - # 개미털기 후보에 대해서만 투자자 동향 확인 (API 호출 최소화) + # 개미털기 후보에 대해서만 투자자 동향 확인 (API 호출 간격 넉넉히 → 초당 거래건수 초과 방지) investor_check_count = 0 + investor_delay = max(0.6, get_env_float("API_DELAY_INVESTOR_SEC", 0.8)) # 종목당 최소 0.8초 for code, candidate in list(all_candidates.items()): if candidate.get('from_ant') and investor_check_count < 10: # 최대 10개만 체크 try: + time.sleep(investor_delay) investor_trend = self.client.get_investor_trend(code, days=2) investor_check_count += 1 if investor_trend: @@ -1705,22 +1951,75 @@ class ShortTradingBot: # 강도 순 정렬 (total_score 기준) final_candidates.sort(key=lambda x: x['score'], reverse=True) - # 강도 4.0 이상만 필터링 (키움 봇과 동일) - filtered = [c for c in final_candidates if c['score'] >= 4.0] - + min_score = get_env_float("UPDATE_UNIVERSE_MIN_SCORE", 4.0) + fallback_top = get_env_int("UPDATE_UNIVERSE_FALLBACK_TOP_N", 30) + filtered = [c for c in final_candidates if c['score'] >= min_score] if not filtered: - logger.warning(f" ⚠️ 강도 4.0 이상 후보 없음 (전체: {len(final_candidates)}개)") - # 강도 낮춰서라도 상위 30개 저장 - filtered = final_candidates[:30] + logger.warning(f" ⚠️ 강도 {min_score} 이상 후보 없음 (전체: {len(final_candidates)}개)") + filtered = final_candidates[:fallback_top] + + # 전일 대비 하락률 하한 필터 (너무 박살난 종목 제외) + min_prev_day_pct = get_env_float("SCAN_MIN_PREV_DAY_PCT", -100.0) + if min_prev_day_pct > -100.0 and filtered: + logger.info(f" 📉 [전일하락 하한] {min_prev_day_pct:.1f}% 미만 종목 제외") + filtered_prev = [] + for c in filtered: + code = c["code"] + name = c.get("name", code) + try: + price_data = self.client.inquire_price(code) + if not price_data: + filtered_prev.append(c) + continue + cur = float(str(price_data.get("stck_prpr", 0)).replace(",", "") or 0) + prev = float(str(price_data.get("stck_prdy_clpr", 0)).replace(",", "") or 0) + if prev <= 0 or cur <= 0: + filtered_prev.append(c) + continue + change_pct = (cur - prev) / prev * 100.0 + if change_pct < min_prev_day_pct: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-전일하락] {name} {code}: 전일대비 {change_pct:.1f}% < {min_prev_day_pct:.1f}%{LOG_RESET}" + ) + continue + filtered_prev.append(c) + except Exception as e: + logger.debug(f"전일하락 필터 조회 실패({name} {code}): {e}") + filtered_prev.append(c) + filtered = filtered_prev + + # 최소 후보 수 확보 (강도 기준을 통과한 종목이 적을 때, 나머지는 점수순으로 채움) + min_candidates = get_env_int("UPDATE_UNIVERSE_MIN_CANDIDATES", 0) + if min_candidates > 0 and len(filtered) < min_candidates and final_candidates: + need = min_candidates - len(filtered) + if need > 0: + existing = {c["code"] for c in filtered} + extras = [] + for c in final_candidates: + if c["code"] in existing: + continue + extras.append(c) + existing.add(c["code"]) + if len(extras) >= need: + break + if extras: + logger.info(f" 📋 [후보보강] 강도컷 통과 {len(filtered)}개 + 추가 {len(extras)}개 → 최소 {min_candidates}개 확보") + filtered.extend(extras) - # DB 저장: 스캔 중 통과 시마다 이미 add_target_candidate()로 실시간 저장됨 → 여기서는 전체 교체 안 함 - # 스캔 완료 후에는 정렬만 해서 로그만 찍음 (매매 루프는 DB에서 실시간으로 후보 읽음) - logger.info(f" 💾 매수 후보군: 스캔 중 통과 즉시 저장 완료, 최종 후보 {len(filtered)}개 (DB 반영됨, 정렬 순 로그만 출력)") + # 강도순 상위 N명만 DB 저장 (매수체크는 이 목록만 사용 → 잡주 제거) + top_n_db = get_env_int("UPDATE_UNIVERSE_TOP_N", 20) + top_for_db = filtered[:top_n_db] + db_payload = [ + {"code": c["code"], "name": c["name"], "score": c["score"], "price": c.get("price", 0)} + for c in top_for_db + ] + self.db.update_target_candidates(db_payload) + logger.info(f" 💾 매수 후보군: 강도순 상위 {len(db_payload)}명 DB 반영 완료") - # Top 5 상세 로그 (강도 순, 종목명 표시) - logger.info(f" 🔝 [유니버스 Top 5] (강도 순)") + top_log = get_env_int("UPDATE_UNIVERSE_TOP_LOG", 5) + logger.info(f" 🔝 [유니버스 Top {top_log}] (강도 순)") - # Top 5 종목의 전일 대비 정보 조회 (API 문서: stck_prdy_clpr = 전일 종가) + # 상위 N종목의 전일 대비 정보 조회 (API 문서: stck_prdy_clpr = 전일 종가) def _safe_float(val): if not val: return 0.0 @@ -1729,11 +2028,14 @@ class ShortTradingBot: except: return 0.0 + # 전일대비 로그용 API 호출 간격 넉넉히 (초당 거래건수 초과 방지) + prev_day_delay = max(0.5, get_env_float("API_DELAY_PREV_DAY_SEC", 1.0)) # 종목당 최소 1초 prev_day_map = {} - for x in filtered[:5]: + for x in filtered[:top_log]: code = x["code"] name = x.get("name", code) try: + time.sleep(prev_day_delay) price_data = self.client.inquire_price(code) if not price_data: logger.info(f" ⚠️ 전일대비: {name} {code} API응답없음") @@ -1741,6 +2043,7 @@ class ShortTradingBot: current_price = _safe_float(price_data.get("stck_prpr")) or _safe_float(x.get("price", 0)) prev_close = _safe_float(price_data.get("stck_prdy_clpr")) # API 문서 필드명 그대로 사용 if prev_close <= 0: # 없으면 일봉에서 전일 close + time.sleep(prev_day_delay) df_daily = self.client.get_daily_chart(code, limit=3) if not df_daily.empty and len(df_daily) >= 2: prev_close = _safe_float(df_daily["close"].iloc[-2]) @@ -1753,11 +2056,10 @@ class ShortTradingBot: prev_day_map[code] = (change, change_pct) else: logger.info(f" ⚠️ 전일대비: {name} {code} prev_close={prev_close}, current={current_price}") - time.sleep(random.uniform(0.2, 0.3)) except Exception as e: logger.warning(f" ⚠️ 전일대비 조회 실패({name} {code}): {e}") - for i, x in enumerate(filtered[:5], 1): + for i, x in enumerate(filtered[:top_log], 1): base = x.get('base_score', 0) bonus = x.get('bonus_score', 0) total = x['score'] @@ -1786,8 +2088,31 @@ class ShortTradingBot: f"낙폭 {drop_pct:.1f}% | 회복 {recovery_pct:.0f}% | {source}{ml_info}{prev_day_info}" ) - logger.info(f" ✅ 최종 후보: {len(filtered)}개 (강도 4.0 이상: {len([c for c in final_candidates if c['score'] >= 4.0])}개)") - + logger.info(f" ✅ 최종 후보: {len(filtered)}개 (강도 {min_score} 이상: {len([c for c in final_candidates if c['score'] >= min_score])}개)") + # [매터모스트 5분 주기 후보 알림] filtered 있으면 발송, 없으면 미발송 + if filtered: + mm_msg = f"🐜 **개미털기 후보 TOP{top_log}** (스캔 {len(final_candidates)}개 중 {len(filtered)}개 통과)\n\n" + for i, x in enumerate(filtered[:top_log], 1): + code_str = x['code'] + name_str = x['name'] + total = x['score'] + + prev_info = "" + if code_str in prev_day_map: + change, change_pct = prev_day_map[code_str] + sign = "+" if change >= 0 else "" + prev_info = f" ({sign}{change_pct:.2f}%)" + + mm_msg += f"강도 {total:.1f} {name_str}{prev_info}\n" + + logger.info(f" 📤 [MM] 5분 주기 후보 알림 발송 중 (채널={self.mm_channel})") + ok = self.send_mm(mm_msg) + if ok: + logger.info(f" 📤 [MM] 후보 알림 발송 완료") + else: + logger.warning(f" 📤 [MM] 후보 알림 발송 실패 (채널/토큰 확인)") + else: + logger.info(f" 📤 [MM] 후보 0개 → 알림 미발송") except Exception as e: logger.error(f"❌ 유니버스 업데이트 실패: {e}") import traceback @@ -1864,16 +2189,171 @@ class ShortTradingBot: logger.error(f"❌ [자산 업데이트 스케줄러] 에러: {e}") await asyncio.sleep(60) - def _update_assets(self): - """자산 정보 업데이트""" + def _sync_holdings_from_balance(self, balance): + """ + 계좌 잔고 API 응답(output1)으로 self.holdings 동기화. + 폰·타 앱으로 매수한 종목이 계좌에 있으면 holdings에 반영하고, 매도한 종목은 제거. + """ try: - balance = self.client.get_account_balance() + output1 = balance.get("output1") or [] + if isinstance(output1, dict): + output1 = [output1] + if not isinstance(output1, list): + return + api_codes = set() + for item in output1: + code = (item.get("pdno") or item.get("PDNO") or "").strip() + if not code: + continue + # 보유수량 (한투: hldg_qty 등) + qty_val = item.get("hldg_qty") or item.get("HLDG_QTY") or item.get("ord_qty") or item.get("ORD_QTY") or 0 + qty = int(float(str(qty_val).replace(",", ""))) if qty_val is not None else 0 + if qty <= 0: + continue + api_codes.add(code) + # 매입평균가 (한투 pchs_avg_pric). 없거나 0이면 평가금액-평가손익으로 역산 + avg_pr_raw = item.get("pchs_avg_pric") or item.get("PCHS_AVG_PRIC") + api_buy_price = 0.0 + if avg_pr_raw is not None: + avg_pr_str = str(avg_pr_raw).replace(",", "").strip() + if avg_pr_str not in ("", "0", "0.0"): + try: + api_buy_price = abs(float(avg_pr_str)) + except Exception: + api_buy_price = 0.0 + if api_buy_price <= 0: + try: + evlu_amt = float(item.get("evlu_amt") or item.get("EVLU_AMT") or 0) + evlu_pfls = float(item.get("evlu_pfls_amt") or item.get("EVLU_PFLS_AMT") or 0) + cost = evlu_amt - evlu_pfls + api_buy_price = cost / qty if qty and cost > 0 else 0.0 + except Exception: + api_buy_price = 0.0 + if api_buy_price <= 0: + api_buy_price = 0.0 + # 매수일시 (API에 있으면 그때로, 없으면 현재) + buy_time_str = item.get("fstc_pchs_dt") or item.get("FSTC_PCHS_DT") or item.get("pchs_dt") or item.get("PCHS_DT") or item.get("ord_dt") or "" + buy_time_str = (buy_time_str or "").strip().replace("-", "").replace(" ", "").replace(":", "") + if len(buy_time_str) >= 14: + try: + buy_time = dt.strptime(buy_time_str[:14], "%Y%m%d%H%M%S").strftime("%Y-%m-%d %H:%M:%S") + except Exception: + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + elif len(buy_time_str) >= 8: + try: + buy_time = dt.strptime(buy_time_str[:8], "%Y%m%d").strftime("%Y-%m-%d %H:%M:%S") + except Exception: + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + else: + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + name = (item.get("prdt_name") or item.get("PRDT_NAME") or item.get("prdt_name_eng") or code or "").strip() + if not name: + name = code + existing = self.holdings.get(code) + # ⇒ buy_price 결정: API > 기존 holdings > DB 순서로 폴백 + buy_price = api_buy_price + existing_buy_price = 0.0 + if existing: + try: + existing_buy_price = float(existing.get("buy_price", 0) or 0) + except Exception: + existing_buy_price = 0.0 + if buy_price <= 0 and existing_buy_price > 0: + buy_price = existing_buy_price + if buy_price <= 0: + db_trade = None + try: + if hasattr(self.db, "get_active_trade"): + db_trade = self.db.get_active_trade(code) + except Exception as e: + logger.debug(f"잔고동기화 DB 평단 조회 실패({code}): {e}") + if db_trade: + try: + db_buy_price = float( + db_trade.get("avg_buy_price") + or db_trade.get("buy_price") + or 0 + ) + except Exception: + db_buy_price = 0.0 + if db_buy_price > 0: + buy_price = db_buy_price + if existing: + if buy_price <= 0: + # 매입가를 신뢰할 수 없으면 기존 매입가를 보존하고 수량/이름만 갱신 + logger.warning( + f"⚠️ [잔고동기화] {name} ({code}) 매입가 복원 실패 → 기존 매입가 유지" + ) + existing["qty"] = qty + if name: + existing["name"] = name + continue + # 수량/매입가 API·DB 기준으로 갱신 (폰에서 추가 매수/일부 매도 반영) + existing["qty"] = qty + existing["buy_price"] = buy_price + if name: + existing["name"] = name + continue + # API에만 있는 종목(폰 등 외부 매수) → holdings에 추가, 기본 손절/목표가 적용 + if buy_price <= 0: + logger.warning( + f"⚠️ [잔고동기화] {name} ({code}) 매입가 0/없음 → holdings 추가 스킵" + ) + continue + stop_price = buy_price * (1 + self.stop_loss_pct) if buy_price > 0 else 0 + target_price = buy_price * (1 + self.take_profit_pct) if buy_price > 0 else 0 + self.holdings[code] = { + "buy_price": buy_price, + "qty": qty, + "buy_time": buy_time, + "name": name, + "max_price": buy_price, + "atr_entry": buy_price * 0.01 if buy_price > 0 else 0, + "stop_price": stop_price, + "target_price": target_price, + } + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SHORT_ANT_SHAKING", + "avg_buy_price": buy_price, + "current_price": buy_price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": buy_time, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": self.holdings[code]["atr_entry"], + }) + logger.info(f"📲 [잔고동기화] 외부 매수 반영: {name} ({code}) {qty}주 @ {buy_price:,.0f}원") + # API에 없는 종목 제거 (다른 경로에서 매도된 경우). output1이 리스트일 때만 적용 + if isinstance(output1, list): + for code in list(self.holdings.keys()): + if code not in api_codes: + name = self.holdings[code].get("name", code) + del self.holdings[code] + try: + self.db.close_trade(code=code, sell_price=0, sell_reason="잔고동기화(외부매도)") + except Exception as e: + logger.debug(f"잔고동기화 close_trade 스킵 {code}: {e}") + logger.info(f"📲 [잔고동기화] 보유 제거: {name} ({code}) - 계좌에 없음") + except Exception as e: + logger.warning(f"잔고 동기화 예외: {e}") + + def _update_assets(self, balance=None): + """자산 정보 업데이트 (잔고 동기화 포함). balance 생략 시 API 호출.""" + try: + if balance is None: + balance = self.client.get_account_balance() if balance is None: logger.warning( "💵 [예수금] get_account_balance가 None 반환 → 예수금 갱신 스킵 " "(토큰·계좌·TR ID 확인. 모의=VTTC8434R, 실전=TTTC8434R)" ) return + # 폰/타 앱 매매 반영: 잔고 API 기준으로 holdings 동기화 + self._sync_holdings_from_balance(balance) # 한투 API: output1=주식 잔고(종목별), output2=예수금 관련(dnca_tot_amt 등) - 블로그·문서 기준 def _parse_amt(v): if v is None or str(v).strip() == "": @@ -1894,24 +2374,18 @@ class ShortTradingBot: out1 = {} ord_psbl_val = _parse_amt(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash")) dnca_tot_val = _parse_amt(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0 - # D+2 예수금 (전일 정산 수령 예정 금액) - 한투 output2 prvs_rcdl_excc_amt + # 거래가능 = D+2 예수금 (prvs_rcdl_excc_amt). 없으면 예수금총액 fallback prvs_rcdl = _parse_amt(out2.get("prvs_rcdl_excc_amt")) if prvs_rcdl is not None: self.d2_excc_amt = prvs_rcdl else: self.d2_excc_amt = 0 - if ord_psbl_val is not None: - self.current_cash = ord_psbl_val - logger.info( - f"💵 [예수금] 주문가능(ord_psbl_cash)={self.current_cash:,.0f}원 | " - f"dnca_tot_amt={dnca_tot_val:,.0f} | D+2예수금(prvs_rcdl_excc_amt)={self.d2_excc_amt:,.0f}원" - ) + if prvs_rcdl is not None and prvs_rcdl > 0: + self.current_cash = prvs_rcdl + logger.info(f"💵 [예수금] 거래가능=D+2예수금={self.current_cash:,.0f}원") else: self.current_cash = dnca_tot_val - logger.info( - f"💵 [예수금] dnca_tot_amt={self.current_cash:,.0f}원 | " - f"D+2예수금(prvs_rcdl_excc_amt)={self.d2_excc_amt:,.0f}원 (output2 keys={list(out2.keys()) if out2 else []})" - ) + logger.info(f"💵 [예수금] 거래가능=예수금총액={self.current_cash:,.0f}원 (D+2 없음)") # 보유 종목 평가액 계산 holdings_value = 0 for code, holding in self.holdings.items(): @@ -1958,12 +2432,12 @@ class ShortTradingBot: prvs_rcdl = _parse_amt_light(out2.get("prvs_rcdl_excc_amt")) if prvs_rcdl is not None: self.d2_excc_amt = prvs_rcdl - if ord_psbl_val is not None: - new_cash = ord_psbl_val + if prvs_rcdl is not None and prvs_rcdl > 0: + new_cash = prvs_rcdl else: new_cash = dnca_tot_val logger.info( - f"💵 [예수금-경량] 주문가능={new_cash:,.0f}원 (이전={self.current_cash:,.0f}) | D+2예수금={self.d2_excc_amt:,.0f}원" + f"💵 [예수금-경량] 거래가능(D+2)={new_cash:,.0f}원 (이전={self.current_cash:,.0f})" ) if new_cash > 0 or self.current_cash == 0: self.current_cash = new_cash @@ -1997,11 +2471,12 @@ class ShortTradingBot: return self._update_account_light(profit_val=0) def send_mm(self, msg): - """Mattermost 알림 전송""" + """Mattermost 알림 전송. 성공 시 True, 실패 시 False.""" try: - self.mm.send(self.mm_channel, msg) + return self.mm.send(self.mm_channel, msg) except Exception as e: logger.error(f"❌ MM 전송 에러: {e}") + return False def check_market_status(self): """장 운영 시간 체크""" @@ -2109,18 +2584,45 @@ class ShortTradingBot: self.final_report_sent = True logger.info("🏁 장마감 최종 리포트 전송 완료") + def _get_env_numeric_snapshot(self): + """DB 최신 env에서 계좌/키/토큰/URL 제외한 수치·설정만 반환 (키=값 줄 단위).""" + 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", + } + latest = self.db.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 = (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 send_ai_report(self): - """AI 분석 리포트 (13:00)""" + """AI 분석 리포트 (13:00) - DB 수치만 보여주고, 거래 내역으로 승률 분석·추천(설정수치=값 형식).""" if self.ai_report_sent or not gemini_client: return - + try: + # DB에서 수치 설정만 (계좌/키/토큰 제외) + env_lines = self._get_env_numeric_snapshot() + # 최근 거래 내역 조회 recent_trades = [] try: conn = self.db.conn cursor = conn.execute(""" - SELECT code, name, buy_price, sell_price, qty, profit_rate, + 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 @@ -2144,55 +2646,54 @@ class ShortTradingBot: except Exception as e: logger.error(f"거래 내역 조회 실패: {e}") return - - # 현재 유니버스 상태 + db_candidates = self.db.get_target_candidates() candidate_count = len(db_candidates) - - # 거래 내역이 없어도 유니버스 상태는 리포트에 포함 + + # 거래 내역 없을 때: 수치만 보여주고 유니버스·추천 if not recent_trades: - # 거래 내역 없을 때도 유니버스 상태 리포트 summary = f"""📊 **현재 상태** - 유니버스 후보: {candidate_count}개 - 최근 거래: 없음""" - prompt = f"""당신은 퀀트 트레이딩 전문가입니다. -**현재 상태:** +**현재 상태** - 유니버스 후보: {candidate_count}개 - 최근 거래: 없음 -**현재 설정값:** -- 최대 보유: {self.max_stocks}개 -- 최소 낙폭: {self.min_drop_rate*100:.1f}% -- 최소 회복률: {self.min_recovery_ratio*100:.0f}% -- ML 사용: {self.use_ml_signal} -- ML 최소 승률: {self.ml_min_probability:.1%} +**현재 DB 설정 수치 (일부만 표시됨, 계좌/키 등 제외)** +``` +{env_lines} +``` -**당신의 임무:** -1. 후보가 {candidate_count}개인 이유 분석 (필터 조건이 너무 까다로운지 등) -2. **수치 추천** (변수명=값 형식, 정확한 숫자 제시) - - 예: MIN_DROP_RATE=0.025 (2.5%) - - 예: MIN_RECOVERY_RATIO=0.40 (40%) -3. 예상 효과 +**당신의 임무** +1. 위 설정과 후보 수({candidate_count}개)를 보고 문제점 분석 (필터가 너무 까다로운지 등). +2. **추천**: 반드시 아래 형식으로만 한 줄에 하나씩 작성. 이유·주석 붙이지 말 것. 그대로 DB에 복붙해 적용할 수 있어야 함. + - 예: MAX_STOCKS=4 + - 예: MIN_DROP_RATE=0.025 + - 예: MIN_RECOVERY_RATIO_SHORT=0.4 +3. 예상 효과 한두 줄. -**출력 형식:** +**출력 형식 (반드시 준수)** ## 🔍 문제점 1. [구체적 문제 1] 2. [구체적 문제 2] -## 💡 수치 추천 (DB 설정 변경) -- 변수명1=값1 (이유: ...) -- 변수명2=값2 (이유: ...) +## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용 가능하게) +MAX_STOCKS=4 +MIN_DROP_RATE=0.025 +(필요한 것만, 변수명은 위 설정 목록에 있는 것만 사용) ## 📈 예상 효과 - [효과 1] -- [효과 2] """ - + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") - + + # 마지막 AI 추천문 저장 (!적용 시 매터모스트 원격 조종용) + _save_ai_recommendations_from_text(self.db, analysis) + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** {summary} @@ -2200,99 +2701,81 @@ class ShortTradingBot: {analysis} --- -💬 단타 전략 최적화를 위한 AI 분석입니다. (수치 추천 포함) +💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다. """ - self.send_mm(message) self.ai_report_sent = True - logger.info("🤖 AI 리포트 전송 완료 (거래 내역 없음, 유니버스 상태 포함)") + logger.info("🤖 AI 리포트 전송 완료 (거래 내역 없음)") return - + # 통계 계산 total = len(recent_trades) wins = sum(1 for t in recent_trades if t['profit_rate'] > 0) losses = total - wins - if total > 0: - win_rate = wins / total * 100 - else: - win_rate = 0 + win_rate = (wins / total * 100) if total > 0 else 0 avg_profit = sum(t['profit_rate'] for t in recent_trades) / total total_pnl = sum(t['realized_pnl'] for t in recent_trades) avg_hold = sum(t['hold_minutes'] for t in recent_trades) / total - - # AI 분석 + trades_text = "" for i, t in enumerate(recent_trades, 1): trades_text += f""" [거래 {i}] {t['name']} ({t['strategy']}) -- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 -- 매도: {t['sell_price']:,.0f}원 -- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) -- 보유: {t['hold_minutes']}분 +- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 | 매도: {t['sell_price']:,.0f}원 +- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) | 보유: {t['hold_minutes']}분 - 사유: {t['sell_reason']} """ - - # 현재 유니버스 상태 - db_candidates = self.db.get_target_candidates() - candidate_count = len(db_candidates) - + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. -**현재 상태:** +**현재 상태** - 유니버스 후보: {candidate_count}개 -- 최근 거래: {total}건 -- 승률: {win_rate:.1f}% ({wins}승 {losses}패) -- 평균 수익률: {avg_profit:.2f}% -- 총 손익: {total_pnl:,.0f}원 -- 평균 보유: {avg_hold:.0f}분 +- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:,.0f}원 | 평균 보유: {avg_hold:.0f}분 -**최근 거래 내역:** +**최근 거래 내역** {trades_text} -**현재 설정값:** -- 최대 보유: {self.max_stocks}개 -- 최소 낙폭: {self.min_drop_rate*100:.1f}% -- 최소 회복률: {self.min_recovery_ratio*100:.0f}% -- ML 사용: {self.use_ml_signal} -- ML 최소 승률: {self.ml_min_probability:.1%} +**현재 DB 설정 수치 (계좌/키 등 제외, 수치만)** +``` +{env_lines} +``` -**당신의 임무:** -1. 문제점 3가지 진단 (구체적으로) - - 특히 후보가 {candidate_count}개인 이유 분석 -2. **수치 추천** (변수명=값 형식, 정확한 숫자 제시) - - 예: MIN_DROP_RATE=0.025 (2.5%) - - 예: MIN_RECOVERY_RATIO=0.40 (40%) - - 예: MAX_STOCKS=5 -3. 예상 효과 +**당신의 임무** +1. **승률이 왜 떨어졌는지** 거래 내역과 설정 수치를 보고 구체적으로 3가지 진단 (손절/진입/보유 등). +2. **추천**: 반드시 "설정수치=값" 한 줄에 하나만. 이유·주석 붙이지 말 것. 그대로 DB에 복붙해 적용할 수 있어야 함. + - 예: MAX_STOCKS=4 + - 예: MIN_DROP_RATE=0.025 + - 예: MIN_RECOVERY_RATIO_SHORT=0.4 + 변수명은 위 설정 목록에 있는 것만 사용. +3. 예상 효과 한두 줄. -**출력 형식:** -## 🔍 문제점 +**출력 형식 (반드시 준수)** +## 🔍 문제점 (승률 하락 원인) 1. [구체적 문제 1] 2. [구체적 문제 2] 3. [구체적 문제 3] -## 💡 수치 추천 (DB 설정 변경) -- 변수명1=값1 (이유: ...) -- 변수명2=값2 (이유: ...) +## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용) +MAX_STOCKS=4 +MIN_DROP_RATE=0.025 +(필요한 것만) ## 📈 예상 효과 - [효과 1] -- [효과 2] - -**간결하고 명확하게 답변하세요.** """ - + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") - + + # 마지막 AI 추천문 저장 (!적용 시 매터모스트 원격 조종용) + _save_ai_recommendations_from_text(self.db, analysis) + summary = f"""📊 **현재 상태** - 유니버스 후보: {candidate_count}개 -- 최근 거래: {total}건 -- 승률: {win_rate:.1f}% ({wins}승 {losses}패) -- 평균 수익률: {avg_profit:.2f}% -- 총 손익: {total_pnl:+,.0f}원 -- 평균 보유: {avg_hold:.0f}분""" - +- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:+,.0f}원 | 평균 보유: {avg_hold:.0f}분""" + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** {summary} @@ -2300,13 +2783,12 @@ class ShortTradingBot: {analysis} --- -💬 단타 전략 최적화를 위한 AI 분석입니다. (수치 추천 포함) +💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다. """ - self.send_mm(message) self.ai_report_sent = True logger.info("🤖 AI 리포트 전송 완료") - + except Exception as e: logger.error(f"AI 리포트 생성 실패: {e}") @@ -2444,340 +2926,37 @@ class ShortTradingBot: return scan_list def scan_ant_shaking_candidates(self, max_candidates=20): - """개미털기(눌림목) 후보 종목 스캔 - KIS API로 스캔 대상 조회 후 필터링""" - logger.info("🐜 [개미털기] 고급 스캔 시작 (KIS API 스캔유니버스 사용)") - logger.info( - " 📌 스캔 대상 리스트는 DB에 저장되지 않음. " - "수치 체크(낙폭/회복률 등)는 이 리스트를 순회하며 개미털기 전략 필터를 적용한 결과입니다." - ) - candidates = [] - seen_codes = set() - # 필터별 탈락 건수 (0개 나오는 이유 확인용) - 세분화 - filter_counts = { - "낙폭부족": 0, - "회복률부족": 0, - "피뢰침(고점근접)": 0, - "피뢰침(급등주)": 0, - "RSI과열": 0, - "MA20": 0, - "API응답없음": 0, - "API예외": 0, - "시가0": 0, - "동전주": 0, - "가격파싱오류": 0, - } + """ + 개미털기(눌림목) 후보 종목 스캔 - kiwoom_trader_dual 방식: 유니버스 리스트만 빠르게 채움. + - 스캔에서는 종목별 API 호출·낙폭/회복 필터·탈락 로그 없음. + - 낙폭/회복/3분봉/RSI/피뢰침/ML 전부 메인 루프의 check_buy_signal_tail_catch에서만 체크. + """ + logger.info("🐜 [개미털기] 스캔 시작 (유니버스 리스트만 등록 → 매수 시 한곳에서 전 조건 체크)") + top_n_light = get_env_int("CANDIDATE_LIST_TOP_N_LIGHT", 20) + max_codes = get_env_int("SCAN_UNIVERSE_MAX_CODES", 150) - scan_list = self._fetch_scan_universe_from_api(max_codes=500) - scan_codes = [x["code"] for x in scan_list] - # scan_list를 딕셔너리로 변환하여 코드로 종목명을 빠르게 찾을 수 있게 함 - scan_name_map = {x["code"]: x.get("name", "") for x in scan_list} - if not scan_codes: - logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → 스캔 생략 (API에서 종목 리스트를 받지 못함)") + scan_list = self._fetch_scan_universe_from_api(max_codes=max_codes) + if not scan_list: + logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → API에서 리스트를 받지 못함") return [] - # 후보 등록 방식: RELAXED면 낙폭+회복만 통과한 상위 N명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점에 적용) - # 기본 True → 후보 풀 확대(6개 수준), False면 기존처럼 전 필터 통과한 종목만 등록 - relaxed = get_env_bool("RELAXED_CANDIDATE_SCAN", False) - top_n = get_env_int("CANDIDATE_LIST_TOP_N", 6) - if relaxed: - logger.info(f" 📌 [RELAXED 모드] 낙폭+회복 통과만으로 후보 수집 → 상위 {top_n}명만 DB 등록 (피뢰침/RSI/MA20은 매수 시점 적용)") + n_save = min(top_n_light, len(scan_list)) + result = [] + for i, x in enumerate(scan_list[:n_save]): + name = (x.get("name") or x["code"]).strip() or x["code"] + # DB 저장은 update_universe()에서 강도 계산·정렬 후 강도순 상위 N명만 함 (여기서는 리스트만 반환) + result.append({ + "code": x["code"], + "name": name, + "price": 0, + "score": 0, + "drop_rate": 0, + "recovery": 0, + }) - # 스캔 대상 리스트를 거래량/낙폭 체크 전에 한 번 출력 (종목명 · 코드) - # 참고: 위 [스캔유니버스] 6소스(거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율) 합산 → 동일 개미털기 필터 적용 - logger.info(f" 📋 [개미털기 스캔 대상] {len(scan_list)}개 (6소스 합산, 종목명 · 코드)") - for i, x in enumerate(scan_list): - logger.info(f" {i+1}. {x.get('name') or x['code']} {x['code']}") - - # 체결강도 상위 API(FHPST01710000) 1회 조회 → 통과 종목 보너스(100 이상 +10, 120 이상 +20) 적용용 - execution_strength_map = {} - try: - time.sleep(random.uniform(0.3, 0.6)) - execution_strength_map = self.client.get_execution_strength_map(market="J", limit=200) - if execution_strength_map: - logger.info(f" 📡 [체결강도] 상위 {len(execution_strength_map)}종목 로드 (통과 시 100+ → +1점, 120+ → +2점)") - except Exception as e: - logger.debug(f"체결강도 맵 로드 스킵: {e}") - - # 순차 조회 (한투 API는 다중 종목 조회 미지원 - 순차 조회 필수) - # 규칙: 일반 루프에는 random.sleep(1~3) 기본 적용 (서버 부하 방지) - total_scanned = 0 - passed_filters = 0 - - for code in scan_codes: - total_scanned += 1 - if code in seen_codes: - continue - seen_codes.add(code) - - try: - # 순차 조회 (종목당 1회 API 호출) - price_data = self.client.inquire_price(code) - if not price_data: - filter_counts["API응답없음"] += 1 - if code == "001510": # SK증권 추적 - logger.warning(f" ⚠️ SK증권(001510) API응답없음 (상세는 위 [현재가API] 로그 참고)") - time.sleep(random.uniform(1.0, 2.0)) - continue - time.sleep(random.uniform(1.0, 2.0)) - - # 가격 데이터 파싱 - try: - current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) - open_price = abs(float(str(price_data.get("stck_oprc", current_price)).replace(",", ""))) - high_price = abs(float(str(price_data.get("stck_hgpr", current_price)).replace(",", ""))) - low_price = abs(float(str(price_data.get("stck_lwpr", current_price)).replace(",", ""))) - volume = int(float(str(price_data.get("acml_vol", 0)).replace(",", ""))) - except (ValueError, TypeError) as e: - filter_counts["가격파싱오류"] += 1 - logger.debug(f"가격 파싱 실패({code}): {e}") - continue - - if open_price == 0: - filter_counts["시가0"] += 1 - continue - if current_price < 1000: # 동전주 제외 - filter_counts["동전주"] += 1 - continue - - # 종목명 가져오기: 시장명(KOSPI/KOSDAQ 등)이 아닌 진짜 종목명만 사용 - # KIS API는 rprs_mrkt_kor_name에 시장구분을 넣는 경우가 있어, stck_kor_isnm(종목한글명) 우선 사용 - _MARKET_NAMES = {"KOSPI", "KOSDAQ", "ETF", "KOSPI200", "KSQ150"} - name = scan_name_map.get(code, "").strip() - if not name or name in _MARKET_NAMES: - name = (price_data.get("stck_kor_isnm") or price_data.get("rprs_mrkt_kor_name") or "").strip() - if not name or name in _MARKET_NAMES: - name = code - - # 낙폭 계산 - if open_price > 0: - drop_rate = (open_price - low_price) / open_price - else: - drop_rate = 0 - total_range = high_price - low_price - if total_range > 0: - recovery_pos = (current_price - low_price) / total_range - else: - recovery_pos = 0 - - # 필터 조건 수치 로그 (디버깅용) - logger.debug( - f" 📊 [{name} {code}] 수치: " - f"낙폭 {drop_rate*100:.2f}% (기준: {self.min_drop_rate*100:.1f}%) | " - f"회복 {recovery_pos*100:.1f}% (기준: {self.min_recovery_ratio*100:.0f}%) | " - f"고점 {high_price:,.0f}원 | 저점 {low_price:,.0f}원 | 현재 {current_price:,.0f}원" - ) - - # [필터 1] 낙폭 체크 - if drop_rate < self.min_drop_rate: - filter_counts["낙폭부족"] += 1 - logger.info( - f"{LOG_YELLOW}🔍 [탈락-낙폭] {name} {code}: 낙폭 {drop_rate*100:.2f}% < {self.min_drop_rate*100:.1f}% " - f"(시가 {open_price:,.0f}원 → 저점 {low_price:,.0f}원){LOG_RESET}" - ) - if code == "001510": # SK증권 추적 - logger.warning(f" ⚠️ SK증권(001510) 낙폭부족으로 탈락: 낙폭={drop_rate*100:.2f}%, 기준={self.min_drop_rate*100:.1f}%") - continue - - # [필터 2] 회복률 체크 - if recovery_pos < self.min_recovery_ratio: - filter_counts["회복률부족"] += 1 - logger.info( - f"{LOG_YELLOW}🔍 [탈락-회복률] {name} {code}: 회복률 {recovery_pos*100:.1f}% < {self.min_recovery_ratio*100:.0f}% " - f"(저점 {low_price:,.0f}원 → 현재 {current_price:,.0f}원 / 범위 {total_range:,.0f}원){LOG_RESET}" - ) - if code == "001510": # SK증권 추적 - logger.warning(f" ⚠️ SK증권(001510) 회복률부족으로 탈락: 회복률={recovery_pos*100:.1f}%, 기준={self.min_recovery_ratio*100:.0f}%") - continue - - # [필터 3~6] RELAXED 모드가 아닐 때만 적용 (후보 풀 확대 시 등록은 넓게, 매수 시점에 엄격 적용) - if not relaxed: - # [필터 3] 피뢰침 방지 - 고점 추격 매수 방지 - high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) - if current_price >= high_price * high_chase_threshold: - filter_counts["피뢰침(고점근접)"] += 1 - drop_from_high = (high_price - current_price) / high_price * 100 - logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요){LOG_RESET}") - continue - - # [필터 4] 피뢰침 방지 - 급등주 제외 - if low_price > 0: - daily_change_pct = (high_price - low_price) / low_price * 100 - else: - daily_change_pct = 0 - max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) - if daily_change_pct > max_daily_change: - filter_counts["피뢰침(급등주)"] += 1 - logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%){LOG_RESET}") - continue - - # [필터 5] RSI 과열 체크 (분봉 데이터 필요) - try: - df = self.client.get_minute_chart(code, period="3", limit=20) - if not df.empty and len(df) >= 14 and "RSI" in df.columns: - rsi = float(df["RSI"].iloc[-1]) - rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) - if rsi >= rsi_threshold: - filter_counts["RSI과열"] += 1 - logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {rsi_threshold}){LOG_RESET}") - continue - - # [필터 6] MA20 체크 - if "MA20" in df.columns and len(df) >= 20: - ma20 = float(df["MA20"].iloc[-1]) - if current_price < ma20: - filter_counts["MA20"] += 1 - logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가({current_price:.0f}) < MA20({ma20:.2f}){LOG_RESET}") - continue - - ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0) - if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100): - filter_counts["MA20"] += 1 - gap_pct = (current_price - ma20) / ma20 * 100 - logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%){LOG_RESET}") - continue - except Exception as e: - logger.debug(f"RSI/MA20 체크 실패({code}): {e}") - - # 외국인/기관 동향 확인 - investor_trend = self.client.get_investor_trend(code, days=3) - investor_score = 0 - if investor_trend: - total_net = investor_trend.get("total_net_buy", 0) - if total_net > 10000: - investor_score = 20 # 강한 매수세 - elif total_net > 0: - investor_score = 10 # 매수세 - - # 조건 통과: 낙폭 3% 이상 & 회복 50% 이상 - if drop_rate >= self.min_drop_rate and recovery_pos >= self.min_recovery_ratio: - # ML 승률 예측 (USE_ML_SIGNAL=true일 때만) - ml_prob = None - if self.use_ml_signal and self.ml_predictor: - try: - # ML 피처 추출 (간단 버전 - 실제로는 더 많은 피처 필요) - # TODO: 실제 피처 데이터로 교체 필요 (RSI, 거래량비, 이동평균 등) - ml_features = { - "rsi": 50.0, # 임시값 - "volume_ratio": 1.0, - "tail_length_pct": drop_rate * 100, - "ma5_gap_pct": 0.0, - "ma20_gap_pct": 0.0, - "foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0, - "institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0, - "market_hour": dt.now().hour, - } - ml_prob = self.ml_predictor.predict_win_probability(ml_features) - - # ML 임계값 미달 시 스킵 - if ml_prob < self.ml_min_probability: - logger.info( - f"{LOG_YELLOW}🔍 [탈락-ML] {name} {code}: ML 승률 {ml_prob:.1%} < {self.ml_min_probability:.1%}{LOG_RESET}" - ) - continue - except Exception as e: - logger.debug(f"ML 예측 실패({code}): {e}") - - # 강도(점수) 계산: 스케일 0~15 전후 (10=높은 편, 5 전후=평범, 한투 체결강도/수급은 소폭 가산) - # 기존 (drop*100 + rec*50)은 30~50대라 평범한 구간이 없었음 → 10 단위로 조정 - score = (drop_rate * 10) + (recovery_pos * 10) # 낙폭·회복 기여 (각 0~10 수준) - if investor_score >= 10: # 수급 보너스 (0 / 1 / 2점) - score += 2 if investor_score >= 20 else 1 - if volume > 1000000: # 거래량 100만주 이상 +1점 - score += 1 - if ml_prob is not None: - score += (ml_prob - 0.5) * 10 # ML 승률 -5~+5점 - # 체결강도 보너스: 100 이상 +1점, 120 이상 +2점 (과도한 가산 방지) - execution_strength = execution_strength_map.get(code, 0) - if execution_strength >= 120: - score += 2 - elif execution_strength >= 100: - score += 1 - candidate = { - "code": code, - "name": name, - "price": current_price, - "score": score, - "drop_rate": drop_rate, - "recovery": recovery_pos, - "volume": volume, - "investor_trend": investor_trend, - "execution_strength": execution_strength, - } - if ml_prob is not None: - candidate["ml_probability"] = ml_prob - - candidates.append(candidate) - passed_filters += 1 - - # 통과 즉시 DB 저장 (RELAXED가 아닐 때만; RELAXED면 루프 끝나고 상위 N명만 일괄 등록) - # 같은 종목 중복 시 ON CONFLICT(code) DO UPDATE 로 최신 점수/가격으로 갱신됨 - if not relaxed: - try: - self.db.add_target_candidate({ - "code": code, - "name": name, - "score": score, - "price": current_price, - }) - except Exception as e: - logger.debug(f"후보 즉시 저장 실패({code}): {e}") - - ml_info = f" | ML {ml_prob:.1%}" if ml_prob is not None else "" - strength_info = f" | 체결강도 {execution_strength:.0f}(+{'2' if execution_strength >= 120 else '1'}점)" if execution_strength >= 100 else "" - logger.info( - f"{LOG_GREEN}✅ [통과] {name} {code}: 낙폭 {drop_rate*100:.1f}% | 회복 {recovery_pos*100:.0f}% | 강도 {score:.1f}{strength_info}{ml_info}{LOG_RESET}" - ) - - except Exception as e: - filter_counts["API예외"] = filter_counts.get("API예외", 0) + 1 - logger.warning( - f"종목 스캔 예외 code={code} exception={e!r} type={type(e).__name__}" - ) - time.sleep(random.uniform(1.0, 2.0)) - continue - - candidates.sort(key=lambda x: x["score"], reverse=True) - - # RELAXED 모드: 낙폭+회복만 통과한 풀에서 상위 N명만 DB 등록 (후보 풀 확대) - if relaxed and candidates: - n_register = min(top_n, len(candidates)) - for c in candidates[:n_register]: - try: - self.db.add_target_candidate({ - "code": c["code"], - "name": c.get("name", c["code"]), - "score": c["score"], - "price": c.get("price", 0), - }) - except Exception as e: - logger.debug(f"후보 등록 실패({c.get('code')}): {e}") - logger.info(f" 📌 [RELAXED] 상위 {n_register}명 DB 등록 (후보 풀 {len(candidates)}개 중 점수순)") - - # 필터별 탈락/통과 요약 (색상: 탈락=노랑, 통과=초록) - summary = ", ".join(f"{k}={v}" for k, v in filter_counts.items() if v > 0) - logger.info(f" 📊 [필터 요약] 스캔 {total_scanned}개 중 {LOG_YELLOW}탈락: {summary or '없음'}{LOG_RESET} | {LOG_GREEN}통과: {len(candidates)}개{LOG_RESET}") - count_ge4 = sum(1 for c in candidates if c.get("score", 0) >= 4.0) - logger.info( - f" ✅ 스캔 완료: 개미털기 {len(candidates)}개 통과 (강도 4.0 이상: {count_ge4}개) " - f"[스캔 {total_scanned}개 → 필터 통과 {passed_filters}개]" - ) - # 통과 종목 전부 출력: 종목명 · 코드 · 강도 (몇 개 안 되므로 전부 표시) - if candidates: - logger.info(" 📌 [개미털기 통과 목록] 종목명 · 코드 · 강도") - for i, c in enumerate(candidates): - logger.info(f" {i+1}. {c['name']} {c['code']} 강도 {c['score']:.1f}") - # 강도순 상위 10개 → Mattermost 전송 - top10 = candidates[:10] - lines = [f"🐜 **개미털기 강도순 TOP{len(top10)}** (스캔 {total_scanned}개 중 통과 {len(candidates)}개)"] - for i, c in enumerate(top10, 1): - name = (c.get("name") or c.get("code") or "").strip() - score = c.get("score", 0) - lines.append(f"{i}. 강도 **{score:.1f}** {name}") - try: - self.send_mm("\n".join(lines)) - except Exception as e: - logger.debug(f"Mattermost 개미털기 TOP10 전송 스킵: {e}") - return candidates[:max_candidates] + part = ", ".join(f"{x.get('name') or x['code']}({x['code']})" for x in scan_list[:5]) + logger.info(f" ✅ [개미털기] 후보 {n_save}명 수집 (강도·정렬·DB 반영은 유니버스 업데이트에서 일괄 처리) | 일부: {part}{' ...' if n_save > 5 else ''}") + return result def calculate_atr(self, df, period=14): """ @@ -2803,6 +2982,256 @@ class ShortTradingBot: logger.debug(f"ATR 계산 실패: {e}") return 0 + def check_buy_signal_tail_catch(self, code: str, name: str): + """ + [3분봉 망치봉 무릎 타점] N자 차트에서 밑꼬리 망치봉이 나온 뒤 무릎~어깨 구간에서만 매수. + - 피뢰침(고점 추격·급등주)은 여기서만 적용 (스캔에서는 후보만 넓게 수집). + - 수치는 전부 get_env_float / get_env_int 등 env·DB 사용. + """ + try: + # [테스트용] DB에서 FORCE_BUY_TEST=true 시 조건 무시하고 현재가로 시그널 반환 (매수 체결 테스트 후 반드시 false로 복구) + if get_env_bool("FORCE_BUY_TEST", False): + try: + price_data = self.client.inquire_price(code) + current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) if price_data else 0 + if current_price <= 0: + return None + logger.info(f"{LOG_CYAN}🧪 [테스트] FORCE_BUY_TEST=true → 조건 건너뛰고 시그널 반환 {name} {code} @ {current_price:,.0f}원{LOG_RESET}") + return { + "code": code, + "name": name, + "price": current_price, + "score": 5.0, + "entry_features": {}, + } + except Exception as e: + logger.warning(f"🧪 FORCE_BUY_TEST 현재가 조회 실패: {e}") + return None + + min_candle_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14) + min_price_tail = get_env_float("MIN_PRICE_TAIL", 1000.0) + df = self.client.get_minute_chart(code, period="3", limit=20) + if df is None or df.empty or len(df) < min_candle_len: + logger.info(f"{LOG_YELLOW}🔍 [탈락-3분봉] {name} {code}: 봉수 부족 (len={len(df) if df is not None and not df.empty else 0}, 기준 {min_candle_len}){LOG_RESET}") + return None + if "close" not in df.columns or "high" not in df.columns or "low" not in df.columns or "open" not in df.columns: + logger.info(f"{LOG_YELLOW}🔍 [탈락-3분봉] {name} {code}: OHLC 컬럼 없음{LOG_RESET}") + return None + + current_price = float(df["close"].iloc[-1]) + candle = df.iloc[-1] + candle_open = float(candle["open"]) + candle_high = float(candle["high"]) + candle_low = float(candle["low"]) + candle_close = float(candle["close"]) + + # 분봉 마지막 봉 close=0인 경우 (장 마감/미체결 봉 등) → 현재가 API로 보정 + if current_price <= 0 or candle_close <= 0: + try: + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) + if current_price > 0: + candle_close = current_price + df = df.copy() + df.loc[df.index[-1], "close"] = current_price + except Exception as e: + logger.debug(f"현재가 보정 실패({code}): {e}") + if candle_open <= 0 and len(df) >= 2: + candle_open = float(df["open"].iloc[-2]) + if candle_open <= 0: + candle_open = float(df["open"].iloc[0]) + + if candle_open <= 0 or current_price < min_price_tail: + logger.info(f"{LOG_YELLOW}🔍 [탈락-가격] {name} {code}: 시가/현재가 부적절 (현재 {current_price:,.0f}원, 최소 {min_price_tail:,.0f}){LOG_RESET}") + return None + + # [수정된 부분: 매수체크 시 당일 진짜 시가, 고가, 저가를 API로 호출해와서 낙폭 계산] + day_open = float(df["open"].iloc[0]) + day_high = float(df["high"].max()) + # 저가가 0인 분봉(비정상 값) 때문에 낙폭이 100%로 계산되는 것을 방지 + try: + lows = df["low"] + valid_lows = lows[lows > 0] + day_low = float(valid_lows.min()) if not valid_lows.empty else float(lows.min()) + except Exception: + day_low = float(df["low"].min()) + + try: + # 3분봉 20개(1시간) 한계를 넘어 하루 전체 기준을 잡기 위해 현재가 API 호출 + today_price_data = self.client.inquire_price(code) + if today_price_data: + api_open = abs(float(str(today_price_data.get("stck_oprc", 0)).replace(",", ""))) + api_high = abs(float(str(today_price_data.get("stck_hgpr", 0)).replace(",", ""))) + api_low = abs(float(str(today_price_data.get("stck_lwpr", 0)).replace(",", ""))) + + if api_open > 0: day_open = api_open + if api_high > 0: day_high = api_high + if api_low > 0: day_low = api_low + except Exception as e: + logger.debug(f"일일 시고저 보정 실패({code}): {e}") + + if day_open > 0 and day_low > 0: + drop_rate = (day_open - day_low) / day_open + else: + drop_rate = 0 + day_range = day_high - day_low + recovery_pos_day = (current_price - day_low) / day_range if day_range > 0 else 0 + if drop_rate < self.min_drop_rate: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-낙폭] {name} {code}: 낙폭 {drop_rate*100:.2f}% < {self.min_drop_rate*100:.1f}% " + f"(당일 시가 {day_open:,.0f}원 → 저점 {day_low:,.0f}원){LOG_RESET}" + ) + return None + if recovery_pos_day < self.min_recovery_ratio: + logger.info( + f"{LOG_YELLOW}🔍 [탈락-회복률] {name} {code}: 회복률 {recovery_pos_day*100:.1f}% < {self.min_recovery_ratio*100:.0f}% " + f"(저점 {day_low:,.0f}원 → 현재 {current_price:,.0f}원 / 범위 {day_range:,.0f}원){LOG_RESET}" + ) + return None + + logger.info(f"{LOG_GREEN}🔍 [통과-낙폭·회복] {name} {code} → 꼬리/피뢰침/RSI 체크{LOG_RESET}") + + # [핵심] 망치봉(밑꼬리) 계산: 꼬리 길이 / 몸통 길이 + # 장 외에는 마지막 봉이 전일 마감봉(open=high=low=close)이라 꼬리 0 → 역순으로 밑꼬리 있는 최근 봉 사용 + tail_lookback = get_env_int("TAIL_CANDLE_LOOKBACK", 5) + body_top = max(candle_open, candle_close) + body_bottom = min(candle_open, candle_close) + body_length = body_top - body_bottom + tail_length = body_bottom - candle_low + if tail_length < 0: + tail_length = 0.0 + if body_length <= 0: + body_length = 1.0 + tail_ratio = tail_length / body_length + tail_pct = (tail_length / candle_low) if (candle_low > 0 and tail_length > 0) else 0 + + # 마지막 봉에 밑꼬리가 없으면(장외/도지) 최근 N개 봉 중 밑꼬리 있는 봉으로 재계산 + if tail_length <= 0 and len(df) >= 2: + for idx in range(len(df) - 2, max(-1, len(df) - 1 - tail_lookback), -1): + c = df.iloc[idx] + o, h, l, cl = float(c["open"]), float(c["high"]), float(c["low"]), float(c["close"]) + if l <= 0: + continue + bt = max(o, cl) + bb = min(o, cl) + bl = bt - bb + tl = bb - l + if tl <= 0: + continue + if bl <= 0: + bl = 1.0 + tail_ratio = tl / bl + tail_pct = (tl / l) if l > 0 else 0 + tail_length = tl + break + + tail_ratio_min = get_env_float("TAIL_RATIO_MIN", 1.5) + tail_pct_min = get_env_float("TAIL_PCT_MIN", 0.003) + if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min: + logger.info(f"{LOG_YELLOW}🔍 [탈락-꼬리] {name} {code}: 꼬리비율 {tail_ratio:.2f} (기준 {tail_ratio_min}) 또는 꼬리% {tail_pct*100:.2f}% (기준 {tail_pct_min*100:.2f}%){LOG_RESET}") + return None + + # [무릎 타점] 그 3분 봉 안에서의 회복률 (무릎~어깨 직전만) — 항상 마지막 봉 기준 + total_range = candle_high - candle_low + recovery_pos = (current_price - candle_low) / total_range if total_range > 0 else 0 + max_rec_3m = get_env_float("MAX_RECOVERY_RATIO_3M", 0.8) + if not (self.min_recovery_ratio <= recovery_pos <= max_rec_3m): + logger.info(f"{LOG_YELLOW}🔍 [탈락-회복3분] {name} {code}: 3분봉 회복률 {recovery_pos*100:.1f}% (기준 {self.min_recovery_ratio*100:.0f}~{max_rec_3m*100:.0f}%){LOG_RESET}") + return None + + # [매수체크 전용] 피뢰침 - 고점 추격 방지 (당일 API 보정된 day_high/day_low 사용) + high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + if current_price >= day_high * high_chase_threshold: + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침 고점추격] {name} {code}: 현재가 {current_price:,.0f} ≥ 고점대비 {high_chase_threshold*100:.0f}%{LOG_RESET}") + return None + # [매수체크 전용] 피뢰침 - 급등주 제외 (당일 API 보정된 day_high/day_low 사용) + if day_low > 0: + range_change_pct = (day_high - day_low) / day_low * 100 + else: + range_change_pct = 0 + max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) + if range_change_pct > max_daily_change: + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침 급등주] {name} {code}: 일일 변동폭 {range_change_pct:.1f}% > {max_daily_change:.0f}%{LOG_RESET}") + return None + + # RSI 과열 방지 (수치: env) + rsi_val = 50.0 + if "RSI" in df.columns and len(df) >= 14: + rsi_val = float(df["RSI"].iloc[-1]) + rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) + if rsi_val >= rsi_threshold: + logger.info(f"{LOG_YELLOW}🔍 [탈락-RSI] {name} {code}: RSI {rsi_val:.1f} ≥ {rsi_threshold:.0f}{LOG_RESET}") + return None + + # MA20: 20일선 아래면 역배열로 패스 (수치: env) + ma5_gap_pct_val, ma20_gap_pct_val, volume_ratio_val = None, None, None + if "MA20" in df.columns and len(df) >= 20: + ma20 = float(df["MA20"].iloc[-1]) + if current_price < ma20: + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가 {current_price:,.0f} < MA20 {ma20:,.0f}{LOG_RESET}") + return None + ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0) + if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100): + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20초과] {name} {code}: MA20 대비 {ma20_cap_pct:.0f}% 초과{LOG_RESET}") + return None + ma20_gap_pct_val = (current_price - ma20) / ma20 * 100 if ma20 > 0 else None + if "MA5" in df.columns and len(df) >= 5: + ma5 = float(df["MA5"].iloc[-1]) + ma5_gap_pct_val = (current_price - ma5) / ma5 * 100 if ma5 and float(ma5) > 0 else None + if "volume" in df.columns and df["volume"].mean() > 0: + volume_ratio_val = float(df["volume"].iloc[-1] / df["volume"].mean()) + + investor_trend = self.client.get_investor_trend(code, days=3) + entry_features = { + "rsi": rsi_val, + "volume_ratio": volume_ratio_val, + "tail_length_pct": tail_pct * 100, + "ma5_gap_pct": ma5_gap_pct_val, + "ma20_gap_pct": ma20_gap_pct_val, + "foreign_net_buy": investor_trend.get("foreign_net_buy", 0) if investor_trend else 0, + "institution_net_buy": investor_trend.get("org_net_buy", 0) if investor_trend else 0, + "market_hour": dt.now().hour, + } + + # ML 승률 (설정 시 env/DB 기준) + if self.use_ml_signal and self.ml_predictor: + try: + ml_features = { + "rsi": entry_features["rsi"], + "volume_ratio": entry_features["volume_ratio"] if entry_features["volume_ratio"] is not None else 1.0, + "tail_length_pct": entry_features["tail_length_pct"], + "ma5_gap_pct": entry_features["ma5_gap_pct"] if entry_features["ma5_gap_pct"] is not None else 0.0, + "ma20_gap_pct": entry_features["ma20_gap_pct"] if entry_features["ma20_gap_pct"] is not None else 0.0, + "foreign_net_buy": entry_features["foreign_net_buy"], + "institution_net_buy": entry_features["institution_net_buy"], + "market_hour": entry_features["market_hour"], + } + ml_prob = self.ml_predictor.predict_win_probability(ml_features) + if ml_prob < self.ml_min_probability: + logger.info(f"{LOG_YELLOW}🔍 [탈락-ML] {name} {code}: 승률 {ml_prob:.2%} < {self.ml_min_probability:.0%}{LOG_RESET}") + return None + except Exception: + pass + + score_base = get_env_float("TAIL_SCORE_BASE", 5.0) + score_ratio_mult = get_env_float("TAIL_SCORE_RATIO_MULT", 2.0) + score = score_base + (tail_ratio * score_ratio_mult) + logger.info( + f"{LOG_CYAN}🎯 [무릎 타점] {name} | 가격:{current_price:,.0f} | " + f"꼬리비율:{tail_ratio:.1f}배 | 회복:{recovery_pos*100:.0f}% | RSI:{rsi_val:.1f}{LOG_RESET}" + ) + return { + "code": code, + "name": name, + "price": current_price, + "score": score, + "entry_features": entry_features, + } + except Exception as e: + logger.info(f"{LOG_YELLOW}🔍 [탈락-예외] {name} {code}: {e}{LOG_RESET}") + return None + def check_sell_signals(self): """ 매도 신호 체크 (ATR 기반 변동성 매도 로직) @@ -2815,6 +3244,7 @@ class ShortTradingBot: return [] sell_signals = [] + min_hold_sec = get_env_float("MIN_HOLD_AFTER_BUY_SEC", 30.0) # 매수 직후 N초 간 매도 신호 무시 (모의투자 잔고 반영 지연 대응) for code, holding in list(self.holdings.items()): try: name = holding.get("name", code) @@ -2822,6 +3252,15 @@ class ShortTradingBot: buy_time_str = holding.get("buy_time", "") qty = holding["qty"] + # 매수 직후 최소 보유 시간 미만이면 매도 체크 스킵 (모의투자 "잔고내역이 없습니다" 방지) + if buy_time_str and min_hold_sec > 0: + try: + buy_time = dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S") + if (dt.now() - buy_time).total_seconds() < min_hold_sec: + continue + except Exception: + pass + # 현재가 조회 price_data = self.client.inquire_price(code) if not price_data: @@ -2882,21 +3321,22 @@ class ShortTradingBot: # ========================================================== # [2] 금액 기준 손절 (원 단위) + # 퍼센트는 작아도 물량이 크면 원화 손실이 커져서 걸림. 0이면 비활성화. # ========================================================== max_loss_per_trade_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000) - if not sell_reason and profit_val <= -max_loss_per_trade_krw: + if max_loss_per_trade_krw > 0 and not sell_reason and profit_val <= -max_loss_per_trade_krw: sell_reason = "금액손실컷" # ========================================================== - # [3] ATR 기반 스캘핑 로직 + # [3] ATR 기반 스캘핑 로직 (수치: env/DB) # ========================================================== if not sell_reason and atr > 0: - # [스캘핑 1] 본절사수: 고점이 ATR 1배 이상 올랐는데 현재가가 ATR 0.2배 이하로 떨어짐 - if (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2): + scalp_up = get_env_float("SCALP_ATR_UP_MULT", 1.0) + scalp_down = get_env_float("SCALP_ATR_DOWN_MULT", 0.2) + scalp_drop = get_env_float("SCALP_ATR_DROP_MULT", 1.0) + if (max_price >= buy_price + atr * scalp_up) and (current_price <= buy_price + atr * scalp_down): sell_reason = "스캘핑_본절사수" - - # [스캘핑 2] 익절보존: 수익 중인데 고점 대비 ATR 1배 이상 하락 - if not sell_reason and current_price < (max_price - atr * 1.0) and profit_pct > 0: + if not sell_reason and current_price < (max_price - atr * scalp_drop) and profit_pct > 0: sell_reason = "스캘핑_익절보존" # 보유 시간 계산 (N자 패턴 등 다음날 상승 가능성 있는 종목 24시간 보유) @@ -2909,37 +3349,37 @@ class ShortTradingBot: pass # ========================================================== - # [4] 빠른 익절 보호 (매수 후 30분 이내) + # [4] 빠른 익절 보호 (매수 후 N시간 이내, 수치: env/DB) # ========================================================== if not sell_reason and hours_passed > 0: use_quick_profit = get_env_bool("USE_QUICK_PROFIT_PROTECTION", True) - if use_quick_profit and hours_passed < 0.5: - if max_price >= buy_price * 1.005 and current_price <= buy_price * 1.0015: + quick_hours = get_env_float("QUICK_PROFIT_PROTECT_HOURS", 0.5) + quick_max_ratio = get_env_float("QUICK_PROFIT_MAX_RATIO", 1.005) + quick_current_min = get_env_float("QUICK_PROFIT_CURRENT_MIN", 1.0015) + if use_quick_profit and hours_passed < quick_hours: + if max_price >= buy_price * quick_max_ratio and current_price <= buy_price * quick_current_min: sell_reason = "💨 작은수익보호" # ========================================================== - # [5] 24시간 보유 전략 (N자 패턴 등 다음날 상승 가능성) + # [5] 24시간 보유 전략 (수치: env/DB) # ========================================================== - # 세력이 N자 만들어서 털어먹으려는 종목은 하루에 안 끝나고 다음날 오르는 경우가 있음 - # 24시간 이내: 특정 조건에서만 매도 (보수적) - # 24시간 이후: 일반 매도 조건 적용 - min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) # 최소 보유 시간 (기본 24시간) - + min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) + early_take_pct = get_env_float("MIN_HOLD_EARLY_TAKE_PCT", 0.05) + hold_high_pct = get_env_float("MIN_HOLD_HIGH_PCT", 1.07) + hold_drop_from_high = get_env_float("MIN_HOLD_DROP_FROM_HIGH", 0.97) + post_hold_take_pct = get_env_float("POST_HOLD_TAKE_PCT", 0.02) + post_hold_drop = get_env_float("POST_HOLD_DROP_FROM_HIGH", 0.97) + if not sell_reason and hours_passed > 0: if hours_passed < min_hold_hours: - # 24시간 이내: 큰 수익(5% 이상) 또는 고점 대비 큰 하락만 매도 - if profit_pct > 0.05: # 5% 이상 수익 - sell_reason = f"💰 {hours_passed:.1f}시간내 5%+ 익절" - elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97: - # 고점 7% 이상 찍고 고점 대비 3% 이상 하락 - sell_reason = f"📈 {hours_passed:.1f}시간내 고점7%→3%하락" - # 그 외에는 24시간 보유 유지 (손절은 제외) + if profit_pct > early_take_pct: + sell_reason = f"💰 {hours_passed:.1f}시간내 {early_take_pct*100:.0f}%+ 익절" + elif max_price >= buy_price * hold_high_pct and current_price <= max_price * hold_drop_from_high: + sell_reason = f"📈 {hours_passed:.1f}시간내 고점→하락" else: - # 24시간 이후: 일반 매도 조건 적용 - if profit_pct > 0.02: # 2% 이상 수익 - sell_reason = f"⏰ {hours_passed:.1f}시간 경과 2%+ 익절" - elif profit_pct > 0 and current_price < max_price * 0.97: - # 수익 중인데 고점 대비 3% 이상 하락 + if profit_pct > post_hold_take_pct: + sell_reason = f"⏰ {hours_passed:.1f}시간 경과 {post_hold_take_pct*100:.0f}%+ 익절" + elif profit_pct > 0 and current_price < max_price * post_hold_drop: sell_reason = f"⏰ {hours_passed:.1f}시간 경과 익절보호" # ========================================================== @@ -3064,12 +3504,17 @@ class ShortTradingBot: # 수량 계산 (수수료 고려) qty = self.risk_mgr.calculate_quantity(price, amount) else: - # 폴백: 기존 고정 슬롯 방식 (RiskManager 미사용 시) + # 폴백: 기존 고정 슬롯 방식 (RiskManager 미사용 시, 수치: env/DB) + slot_default = int(get_env_float("SLOT_MONEY_DEFAULT", 100000.0)) if self.max_stocks > 0: slot_money = int(self.current_cash * 0.9 / self.max_stocks) else: - slot_money = 100000 - base_amount = min(slot_money, 100000) + slot_money = slot_default + base_amount = slot_money + # 슬롯 캡(손절 기반 상한) 적용: MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT| 와 SLOT_BASE_AMOUNT_CAP 중 작은 값 + effective_slot_cap = self._get_effective_slot_cap() + if effective_slot_cap > 0: + base_amount = min(base_amount, int(effective_slot_cap)) if self.stop_loss_pct != 0: stop_pct_abs = abs(self.stop_loss_pct) else: @@ -3078,12 +3523,14 @@ class ShortTradingBot: kelly_risk_amount = self.current_cash * self.risk_pct_per_trade * self.kelly_multiplier kelly_based_amount = int(kelly_risk_amount / stop_pct_abs) base_amount = min(base_amount, kelly_based_amount) + small_ratio = get_env_float("SIZE_CLASS_SMALL_RATIO", 0.7) + mid_ratio = get_env_float("SIZE_CLASS_MID_RATIO", 0.85) if size_class == "소": - amount = int(base_amount * 0.7) - logger.info(f"💰 [{name}] 소형주 → 매수 금액 70%: {amount:,.0f}원") + amount = int(base_amount * small_ratio) + logger.info(f"💰 [{name}] 소형주 → 매수 금액 {small_ratio*100:.0f}%: {amount:,.0f}원") elif size_class == "중": - amount = int(base_amount * 0.85) - logger.info(f"💰 [{name}] 중형주 → 매수 금액 85%: {amount:,.0f}원") + amount = int(base_amount * mid_ratio) + logger.info(f"💰 [{name}] 중형주 → 매수 금액 {mid_ratio*100:.0f}%: {amount:,.0f}원") else: amount = base_amount max_limit = int(self.current_cash * self.max_position_pct) @@ -3123,37 +3570,64 @@ class ShortTradingBot: else: atr = price * 0.01 # 기본값 1% - success = self.client.buy_market_order(code, qty) - if success: - self._update_account_light(profit_val=0) - buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") - self.holdings[code] = { - "buy_price": price, - "qty": qty, - "buy_time": buy_time, - "name": name, - "max_price": price, # 고점 추적 - "atr_entry": atr, # 매수 시점 ATR 저장 - "stop_price": stop_price, # 손절가 - "target_price": target_price, # 목표가 - } - self.db.upsert_trade({ - "code": code, - "name": name, - "strategy": "SHORT_ANT_SHAKING", - "avg_buy_price": price, - "current_price": price, - "target_qty": qty, - "current_qty": qty, - "status": "HOLDING", - "buy_date": buy_time, - "stop_price": stop_price, - "target_price": target_price, - "atr_entry": atr, - }) - logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") - return True - return False + odno = self.client.buy_market_order(code, qty) + if not odno: + # 매매불가 종목이면 당일 제외 목록에 넣고 다음 후보로 넘어가기 + msg_cd = getattr(self.client, "_last_order_msg_cd", None) or "" + msg1 = getattr(self.client, "_last_order_msg1", "") or "" + if msg_cd == "40070000" or "매매불가" in msg1: + self.untradable_skip_set.add(code) + logger.warning(f"🚫 [{name}] 매매불가 종목 -> 당일 매수 제외, 다음 후보로 넘어감") + return False + # 주문 접수 후 체결 조회 API로 실제 체결가/체결수량 확인 (주문만 보고 가정하지 않음) + fill = self.client.get_execution_by_odno(odno, code=code, wait_sec=2) + if fill: + price = fill["avg_price"] + qty = fill["filled_qty"] + if qty <= 0: + logger.warning(f"⚠️ [{name}] 체결 조회 수량 0 -> 매수 반영 스킵") + return False + else: + # 체결 조회 실패 시 주문 수량/시그널가로 반영 (기존 동작, 로그로 구분) + logger.info(f"📋 [{name}] 체결 조회 미확인 -> 주문기준으로 반영 (가격={price:,.0f}, 수량={qty})") + self._update_account_light(profit_val=0) + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + self.holdings[code] = { + "buy_price": price, + "qty": qty, + "buy_time": buy_time, + "name": name, + "max_price": price, # 고점 추적 + "atr_entry": atr, # 매수 시점 ATR 저장 + "stop_price": stop_price, # 손절가 + "target_price": target_price, # 목표가 + } + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SHORT_ANT_SHAKING", + "avg_buy_price": price, + "current_price": price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": buy_time, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": atr, + "entry_features": signal.get("entry_features"), + }) + if fill: + logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 (API 체결 확인) | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + else: + logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 (주문기준) | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + # 체결 알림 (MM) + try: + mm_msg = f"🟢 **매수 체결** {name} ({code})\n{price:,.0f}원 × {qty:,}주 | 손절 {stop_price:,.0f}원 / 목표 {target_price:,.0f}원" + self.send_mm(mm_msg) + except Exception as e: + logger.debug(f"매수 체결 MM 발송 스킵: {e}") + return True def execute_sell(self, signal): """매도 실행""" @@ -3193,6 +3667,13 @@ class ShortTradingBot: self._update_account_light(profit_val=profit_val) logger.info(f"💸 [매도 체결] {name} ({code}): {qty}주 ({signal['reason']}, {signal['profit_pct']*100:+.2f}%)") + # 체결 알림 (MM) + try: + pct = signal['profit_pct'] * 100 + mm_msg = f"🔴 **매도 체결** {name} ({code})\n{sell_price:,.0f}원 × {qty:,}주 | {signal['reason']} | 수익률 {pct:+.2f}%" + self.send_mm(mm_msg) + except Exception as e: + logger.debug(f"매도 체결 MM 발송 스킵: {e}") return True return False @@ -3205,6 +3686,21 @@ class ShortTradingBot: """비동기 메인 루프 - 백그라운드 태스크 시작 후 동기 매매 루프 실행""" logger.info("🚀 단타 트레이딩 봇 시작 (비동기 백그라운드 작업 활성화)") + # 매터모스트 원격 조종 리스너 (!적용 / !설정 → DB 반영 후 reload_config) + try: + from mm_remote import MattermostRemoteController + self.mm_remote = MattermostRemoteController( + server_url=MM_SERVER_URL, + bot_token=MM_BOT_TOKEN, + channel_alias=self.mm_channel, + mm_config_path=MM_CONFIG_FILE, + db=self.db, + ) + self.mm_remote.start() + except Exception as e: + logger.warning("⚠️ MM 원격 조종 리스너 시작 실패: %s", e) + self.mm_remote = None + # 백그라운드 태스크 시작 self._universe_task = asyncio.create_task(self._universe_scan_scheduler()) self._report_task = asyncio.create_task(self._report_scheduler()) @@ -3221,6 +3717,9 @@ class ShortTradingBot: while True: try: + # ★ DB 설정 실시간 반영 (재시작 없이 적용) + self.reload_config() + now = dt.now() current_date = now.strftime("%Y-%m-%d") @@ -3255,38 +3754,57 @@ class ShortTradingBot: # if now.minute % 30 == 0: # 30분마다 # self._update_assets() - # 매도 신호 체크 (우선) - 메인 루프에서 처리 + # 매도 신호 체크 (우선) sell_signals = self.check_sell_signals() for signal in sell_signals: self.execute_sell(signal) - db_candidates = self.db.get_target_candidates() - if db_candidates: - logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(db_candidates)}개 | 보유:{active_count}/{self.max_stocks}") - # DB 후보군이 있으면 사용 - for db_item in db_candidates[:1]: # 상위 1개만 - code = db_item['code'] - name = db_item['name'] - # 실제 가격 확인 후 매수 - price_data = self.client.inquire_price(code) - if price_data: - current_price = abs(float(price_data.get("stck_prpr", 0))) - candidate = { - 'code': code, - 'name': name, - 'price': current_price, - 'score': db_item.get('score', 0), - } - self.execute_buy(candidate) + + # 매수: 후보 종목을 3분봉으로 실시간 타점 체크 → 그 순간에만 매수 (키움 TAIL_CATCH 방식) + today_str = dt.now().strftime("%Y-%m-%d") + if today_str != self.today_date: + self.today_date = today_str + self.untradable_skip_set.clear() + logger.debug("📅 날짜 변경 -> 매매불가 제외 목록 초기화") + active_count = len(self.holdings) + db_candidates = self.db.get_target_candidates() + if db_candidates and active_count < self.max_stocks: + strength_preview = " | 강도순: " + ", ".join( + f"{c.get('name', c.get('code',''))} {c.get('score', 0):.1f}" for c in db_candidates[:5] + ) if db_candidates else "" + logger.info(f"🔍 [매수체크] 후보 {len(db_candidates)}명 순회 (보유 {active_count}/{self.max_stocks}){strength_preview}") + signals_this_turn = 0 + attempts_this_turn = 0 + for db_item in db_candidates: + code = db_item.get("code") or db_item.get("stk_cd", "") + name = db_item.get("name") or db_item.get("stk_nm", code) + if not code or code in self.holdings: + continue + # 매매불가 종목은 당일 재시도 안 함 → 다음 후보로 + if code in self.untradable_skip_set: + continue + signal = self.check_buy_signal_tail_catch(code, name) + if signal: + signals_this_turn += 1 + attempts_this_turn += 1 + logger.info(f"🛒 [매수 시도] {name} ({code}) {attempts_this_turn}건째") + ok = self.execute_buy(signal) + if ok: + logger.info(f"✅ [매수체크] 이번 턴 시그널 {signals_this_turn}건, 시도 {attempts_this_turn}건, 성공 1건") time.sleep(random.uniform(1, 2)) break - else: - # DB 후보군이 없으면 대기 (유니버스 업데이트 대기) - # ⚠️ 직접 스캔하지 않음 - 백그라운드 태스크에서 5분마다 업데이트됨 - if active_count == 0: # 첫 실행 시에만 로그 - logger.info(f"🔍 [매수 기회 탐색] 타겟:0개 (유니버스 스캔 대기 중) | 보유:{active_count}/{self.max_stocks}") + # 실패(매매불가, 금액0, 예수금 부족, API 오류 등) -> 다음 후보로 순회 + time.sleep(random.uniform(0.3, 0.8)) + continue + time.sleep(random.uniform(0.5, 1.0)) + if signals_this_turn == 0 and db_candidates: + logger.info(f"🔍 [매수체크] 이번 순회 시그널 0건 (조건 통과한 후보 없음) → 다음 턴에 재시도") + elif attempts_this_turn > 0 and len(self.holdings) == active_count: + logger.info(f"🔍 [매수체크] 이번 턴 시그널 {signals_this_turn}건, 시도 {attempts_this_turn}건, 체결 0건 (실패 후 다음 턴)") + elif not db_candidates and active_count == 0: + logger.debug("🔍 [매수] 타겟 0개 (유니버스 스캔 대기 중)") - # 대기 - time.sleep(random.uniform(3, 5)) + # 대기 (너무 길면 매수 타점 놓침 → 2~4초로 단축) + time.sleep(random.uniform(2, 4)) except KeyboardInterrupt: logger.info("⏹ 봇 종료") diff --git a/kis_short_ver2.py b/kis_short_ver2.py index 618d0dd..9846338 100644 --- a/kis_short_ver2.py +++ b/kis_short_ver2.py @@ -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 무결성 검증용""" diff --git a/kis_short_ver3.py b/kis_short_ver3.py new file mode 100644 index 0000000..05a5ef3 --- /dev/null +++ b/kis_short_ver3.py @@ -0,0 +1,3658 @@ +""" +KIS Short Trading Bot Ver3 — 꼬리잡기 봇 (tail_engine 공통 로직, 단독 실행) +- Ver2와 동일한 한투 API/WS/봇 구조, 매수/매도 판단만 tail_engine 사용 (백테스트와 동일). +- kis_short_ver2 import 없이 단독 실행 가능 (본 파일에 필요한 코드 포함). +""" + +import os +import re +import json +import time +import random +import logging +import datetime +import hashlib +import hmac +import base64 +import warnings +import asyncio +import subprocess +from datetime import datetime as dt +from pathlib import Path +from typing import List, Dict, Optional + +import pandas as pd +import requests + +from database import TradeDB, ENV_CONFIG_KEYS +import tail_engine as te + +# WebSocket 실시간 체결가 캐시 + 봉집계기 (REST 폴링 전면 대체) +# CandleAggregator: 틱 → 3분봉 in-memory 집계 → check_buy_signal_tail_catch / check_sell_signals / execute_buy 에서 활용 +try: + from kis_ws import ( + KISWebSocketPriceCache, CandleAggregator, + get_kiwoom_candles_df, _get_kiwoom_creds, + ) + _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("KISShortBot") + +# 로그 색상 (ANSI) - 탈락/통과 구분 +LOG_RED = "\033[91m" # 탈락 +LOG_YELLOW = "\033[93m" # 탈락 (Pass-조건) +LOG_GREEN = "\033[92m" # 통과 +LOG_CYAN = "\033[96m" # 강조 +LOG_RESET = "\033[0m" + +# DB 초기화 — MariaDB 192.168.0.141 (database.py 모듈 상수 사용) +SCRIPT_DIR = Path(__file__).resolve().parent +db = TradeDB() # db_path 인수 무시됨, MariaDB 직접 연결 + +# 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") + +# Mattermost 설정 +MM_SERVER_URL = get_env_from_db("MM_SERVER_URL", "https://mattermost.hoonfam.org") +MM_BOT_TOKEN = get_env_from_db("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = SCRIPT_DIR / "mm_config.json" +# 기본 채널(alias) + 단타 봇 전용 채널(alias) +MM_CHANNEL_DEFAULT = get_env_from_db("MATTERMOST_CHANNEL", "stock") +MM_CHANNEL_SHORT = get_env_from_db("KIS_SHORT_MM_CHANNEL", MM_CHANNEL_DEFAULT) + +# Gemini API (AI 리포트용) - google.genai 신규 SDK (Client 사용, configure 없음) +try: + import google.genai as genai + GEMINI_AVAILABLE = True +except ImportError: + GEMINI_AVAILABLE = False + logger.warning("⚠️ google-genai 미설치 - AI 리포트 기능 사용 불가") + +GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip() +GEMINI_MODEL_ID = "gemini-2.5-flash" # 또는 gemini-2.5-flash (모델명 확인) +gemini_client = None +if GEMINI_API_KEY and GEMINI_AVAILABLE: + try: + gemini_client = genai.Client(api_key=GEMINI_API_KEY) + except Exception as e: + logger.warning(f"⚠️ Gemini 초기화 실패: {e}") + gemini_client = None +else: + gemini_client = None + +# ML 예측 (선택적) +try: + from ml_predictor import MLPredictor + ML_AVAILABLE = True +except ImportError: + ML_AVAILABLE = False + logger.warning("⚠️ ml_predictor 미설치 - ML 예측 기능 사용 불가") + +# RiskManager (변동성 기반 리스크 관리) +try: + from risk_manager import RiskManager + RISK_MANAGER_AVAILABLE = True +except ImportError: + RISK_MANAGER_AVAILABLE = False + logger.warning("⚠️ risk_manager 미설치 - 변동성 역가중 매수 금액 계산 불가") + +# ============================================================ +# 한투(KIS) API 클라이언트 (kis_long_term_checker.py 참고) +# ============================================================ +# 모의계좌용 토큰 캐시 경로 +KIS_TOKEN_CACHE_PATH_MOCK = SCRIPT_DIR / ".kis_token_cache_mock.json" +# 실계좌용 토큰 캐시 경로 +KIS_TOKEN_CACHE_PATH_REAL = SCRIPT_DIR / ".kis_token_cache_real.json" + + + +# 한투 접근 토큰 유효기간 24시간. 자주 발급하면 영구 제명될 수 있으므로 캐시 철저 재사용. +# 만료 1분 전에만 재발급 (불필요한 발급 최소화) +KIS_TOKEN_EXPIRE_MARGIN_SEC = 60 + + +def _parse_kis_token_expired(expired_str): + """한투 API 만료시간 문자열 파싱. 'YYYY-MM-DD HH:MM:SS' 또는 'YYYY-MM-DDTHH:MM:SS' 등 지원.""" + if not expired_str or not isinstance(expired_str, str): + return None + s = expired_str.strip().replace("T", " ")[:19] + if len(s) < 19: + return None + try: + return dt.strptime(s, "%Y-%m-%d %H:%M:%S") + except ValueError: + return None + + +def _load_kis_token_cache(mock): + """캐시 파일에서 토큰 로드. 만료 1분 전까지 유효하면 재사용 (24h 토큰 자주 발급 시 영구 제명 주의).""" + if mock: + path = KIS_TOKEN_CACHE_PATH_MOCK + else: + path = KIS_TOKEN_CACHE_PATH_REAL + if not path.exists(): + logger.info("한투 토큰 캐시 없음 → API 발급 예정 (캐시 경로: %s)", path) + return None + try: + logger.info("패스 %s", path) + with open(path, "r", encoding="utf-8") as f: + cache = json.load(f) + if cache.get("mock") != mock: + logger.info("한투 토큰 캐시 모의/실전 불일치 → API 발급 예정") + return None + token = cache.get("access_token") + expired_str = cache.get("access_token_token_expired") or cache.get("expires_at") + if not token or not expired_str: + logger.info("한투 토큰 캐시 내용 불완전 → API 발급 예정") + return None + expired_dt = _parse_kis_token_expired(expired_str) + if expired_dt is None: + logger.info("한투 토큰 캐시 만료시간 파싱 실패(%s) → API 발급 예정", expired_str[:30]) + return None + if dt.now() >= expired_dt - datetime.timedelta(seconds=KIS_TOKEN_EXPIRE_MARGIN_SEC): + logger.info("한투 토큰 캐시 만료 임박(%s) → API 발급 예정", expired_str[:19]) + return None + return token + except Exception as e: + logger.warning("한투 토큰 캐시 로드 실패(%s): %s", path, e) + return None + + +def _save_kis_token_cache(access_token, access_token_token_expired, mock): + """발급받은 토큰을 캐시 파일에 저장.""" + try: + if mock: + path = KIS_TOKEN_CACHE_PATH_MOCK + else: + path = KIS_TOKEN_CACHE_PATH_REAL + with open(path, "w", encoding="utf-8") as f: + json.dump({ + "access_token": access_token, + "access_token_token_expired": access_token_token_expired, + "mock": mock, + }, f, ensure_ascii=False, indent=2) + logger.info("한투 토큰 캐시 저장 완료: %s", path) + except Exception as e: + logger.warning("한투 토큰 캐시 저장 실패: %s", e) + + +def _invalidate_kis_token_cache(mock): + """토큰 만료(EGW00123) 시 캐시 파일 삭제 → 다음 _auth()에서 새 토큰 발급.""" + path = KIS_TOKEN_CACHE_PATH_MOCK if mock else KIS_TOKEN_CACHE_PATH_REAL + try: + if path.exists(): + path.unlink() + logger.info("한투 토큰 캐시 삭제 (만료 감지): %s", path) + except Exception as e: + logger.warning("한투 토큰 캐시 삭제 실패(%s): %s", path, e) + + +def _is_token_expired_response(j): + """응답이 '기간이 만료된 token' 오류(EGW00123)인지 여부.""" + if not j or not isinstance(j, dict): + return False + msg_cd = j.get("msg_cd") or "" + msg1 = str(j.get("msg1", "")) + return msg_cd == "EGW00123" or "만료된 token" in msg1 or "만료" in msg1 + + +class KISClient: + """한국투자증권 Open API 클라이언트""" + def __init__(self, mock=None): + # 실전/모의 토큰 모두 최신 상태로 유지 (모드와 무관하게 양쪽 갱신) + try: + from kis_token_manager import ensure_both_tokens + ensure_both_tokens() + except Exception as _te: + logger.warning(f"토큰 자동갱신 건너뜀: {_te}") + + # 모의 여부 결정 + if mock is not None: + use_mock = mock + else: + use_mock = get_env_bool("KIS_MOCK", True) + + # 모의투자는 MOCK 전용 키만 사용(실전 키로 폴백 안 함 → 토큰/캐시가 실전이랑 섞이지 않도록) + if use_mock: + self.app_key = get_env_from_db("KIS_APP_KEY_MOCK", "").strip() + self.app_secret = get_env_from_db("KIS_APP_SECRET_MOCK", "").strip() + if not self.app_key or not self.app_secret: + logger.error("❌ 모의투자용 APP KEY/SECRET이 DB에 없습니다. KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK 설정 필요.") + raise ValueError("모의투자 KIS_APP_KEY_MOCK / KIS_APP_SECRET_MOCK 미설정") + else: + self.app_key = (get_env_from_db("KIS_APP_KEY_REAL", "") or get_env_from_db("KIS_APP_KEY", "")).strip() + self.app_secret = (get_env_from_db("KIS_APP_SECRET_REAL", "") or get_env_from_db("KIS_APP_SECRET", "")).strip() + + # 계좌번호: 모의/실전 분리 + if use_mock: + raw_no = get_env_from_db("KIS_ACCOUNT_NO_MOCK", "").strip() + raw_code = get_env_from_db("KIS_ACCOUNT_CODE_MOCK", "").strip() + if not raw_code: + raw_code = "01" + else: + raw_no = (get_env_from_db("KIS_ACCOUNT_NO_REAL", "") or get_env_from_db("KIS_ACCOUNT_NO", "")).strip() + raw_code = (get_env_from_db("KIS_ACCOUNT_CODE_REAL", "") or get_env_from_db("KIS_ACCOUNT_CODE", "01")).strip() + if not raw_code: + raw_code = "01" + + # 10자리면 앞 8 / 뒤 2 분리 + if len(raw_no) >= 10: + self.acc_no = raw_no[:8] + self.acc_code = raw_no[8:10] + else: + self.acc_no = raw_no + self.acc_code = raw_code[:2] if len(raw_code) >= 2 else "01" + if len(self.acc_no) != 8: + logger.warning("⚠️ 계좌번호 CANO 8자리 아님: '%s'(%s자리). DB 확인.", self.acc_no, len(self.acc_no)) + + if len(self.acc_no) != 8 or len(self.acc_code) != 2: + logger.error( + "❌ 계좌번호 형식 오류: CANO=%s(%s자리), ACNT_PRDT_CD=%s(%s자리) → OPSQ2000 발생. " + "모의면 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK, 실전이면 KIS_ACCOUNT_NO/KIS_ACCOUNT_CODE 확인.", + self.acc_no, len(self.acc_no), self.acc_code, len(self.acc_code) + ) + else: + logger.info("✅ 한투 계좌 CANO=%s, ACNT_PRDT_CD=%s (모의=%s)", self.acc_no, self.acc_code, use_mock) + + self.mock = use_mock + + if self.mock is True: + self.base_url = "https://openapivts.koreainvestment.com:29443" + else: + self.base_url = "https://openapi.koreainvestment.com:9443" + + self.access_token = None + # 매수 주문 실패 시 사유 저장 (매매불가 종목 당일 제외용) + self._last_order_msg_cd = None + self._last_order_msg1 = None + # 현재가 API 캐시 (레이트리밋·지연 완화용) + # {종목코드: (timestamp, output_dict)} + self._price_cache = {} + logger.info("한투 API 연결: 모의=%s → %s", self.mock, self.base_url) + self._auth() + + + def _auth(self): + """접근 토큰 발급""" + if not self.app_key or not self.app_secret: + if self.mock: + key_hint = "KIS_APP_KEY_MOCK, KIS_APP_SECRET_MOCK" + else: + key_hint = "KIS_APP_KEY_REAL, KIS_APP_SECRET_REAL (또는 KIS_APP_KEY, KIS_APP_SECRET)" + logger.error("한투 API 키가 없습니다. DB env_config에 설정 필요: %s", key_hint) + raise ValueError("KIS 앱키/시크릿 설정 필요 (모의=%s)" % self.mock) + + # ✅ path를 먼저 정의 (발급 성공/실패 양쪽에서 사용) + path = KIS_TOKEN_CACHE_PATH_MOCK if self.mock else KIS_TOKEN_CACHE_PATH_REAL + mode_str = "모의" if self.mock else "실전" + + cached = _load_kis_token_cache(self.mock) + if cached: + self.access_token = cached + token_head = (cached[:8] + "…") if cached and len(cached) > 8 else "(없음)" + logger.info("한투 토큰 캐시 사용 (%s) | 파일=%s | 토큰앞8=%s", mode_str, path, token_head) + return + + # 캐시 없음/만료 → kis_token_manager 경로로만 발급 (잠금·1일1회 준수, SMS 시각 안정화) + try: + 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.warning("kis_token_manager 발급 실패, 재시도: %s", e) + raise RuntimeError("한투 토큰 발급 실패 (캐시 없음·만료 시 kis_token_manager.ensure_token 사용)") + + def _get_hashkey(self, body): + """해시키(Hashkey) 발급 - POST 요청 시 body 무결성 검증용""" + try: + url = f"{self.base_url}/uapi/hashkey" + headers = { + "content-type": "application/json", + "appkey": self.app_key, + "appsecret": self.app_secret, + } + r = requests.post(url, headers=headers, json=body, timeout=5) + if r.status_code == 200: + data = r.json() + if data.get("rt_cd") == "0": + return data.get("HASH") + return None + except Exception as e: + logger.debug(f"해시키 발급 실패: {e}") + return None + + def _headers(self, tr_id, hashkey=None): + """ + 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}", + "appkey": self.app_key, + "appsecret": self.app_secret, + "tr_id": tr_id, + } + if hashkey: + headers["hashkey"] = hashkey + return headers + + def _get(self, path, tr_id, params, max_retries=5, tr_cont=None): + """ + GET 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 후 재시도 (200/500 공통). + EGW00123(기간이 만료된 token) 시 캐시 삭제 후 새 토큰 발급·1회 재시도. + - 한투 API 제한: 초당 20개 (실제로는 더 엄격, 모의투자는 초당 2~3회 권장) + - 기본 호출 간격: 0.5초 이상 권장 + """ + url = f"{self.base_url}{path}" + if tr_cont: + headers_extra = {"tr_cont": tr_cont} + else: + headers_extra = {} + logger.debug(f"[API호출] GET {path} TR_ID={tr_id} params={params} tr_cont={tr_cont}") + time.sleep(0.5) + + token_refreshed = False + for attempt in range(max_retries): + try: + headers = self._headers(tr_id) + for k, v in headers_extra.items(): + headers[k] = v + r = requests.get(url, headers=headers, params=params, timeout=15) + + # HTTP 429 (Too Many Requests) + if r.status_code == 429: + wait_time = 1 + (attempt * 1) + logger.warning( + f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) path={path}" + ) + time.sleep(wait_time) + continue + + # 200/500 응답에서 EGW00201(초당 거래건수 초과)이면 대기 후 재시도 + 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": + logger.debug(f"[API성공] GET {path} TR_ID={tr_id} status=200 rt_cd=0") + return r + 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} TR_ID={tr_id} -> {wait_time:.1f}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) msg1={j.get('msg1', '')}" + ) + time.sleep(wait_time) + continue + # EGW00123: 기간이 만료된 token → 캐시 삭제 후 새 토큰 발급, 1회만 재시도 + if _is_token_expired_response(j) and not token_refreshed: + token_refreshed = True + _invalidate_kis_token_cache(self.mock) + self._auth() + logger.info("한투 토큰 만료(EGW00123) 감지 → 캐시 삭제 후 재발급, GET 재시도") + time.sleep(0.5) + continue + # HTTP 200이 아니거나 rt_cd != "0"인 경우 + try: + body_preview = (r.text or "")[:500] + except Exception: + body_preview = "" + logger.warning( + f"[API실패] GET {path} TR_ID={tr_id} status={r.status_code} " + f"params={params} body={body_preview}" + ) + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ GET 요청 실패 ({path}): {e}") + return r + + def _post(self, path, tr_id, body, use_hashkey=True, max_retries=3): + """ + POST 요청. EGW00201(초당 거래건수 초과) 시 점진적 대기 후 재시도. + EGW00123(기간이 만료된 token) 시 캐시 삭제 후 새 토큰 발급·1회 재시도. + - 한투 API 제한: 초당 20개 (실제로는 더 엄격) + """ + url = f"{self.base_url}{path}" + body_preview = str(body)[:200] if body else "{}" + logger.debug(f"[API호출] POST {path} TR_ID={tr_id} body={body_preview}...") + time.sleep(0.5) + + token_refreshed = False + for attempt in range(max_retries): + try: + hashkey = self._get_hashkey(body) if use_hashkey else None + if use_hashkey and not hashkey: + logger.debug("해시키 발급 실패, 해시키 없이 진행") + r = requests.post(url, headers=self._headers(tr_id, hashkey), json=body, timeout=15) + + # HTTP 429 (Too Many Requests) + if r.status_code == 429: + wait_time = 5 + (attempt * 1) + logger.warning( + f"⏳ API 호출 제한 (429) -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) path={path}" + ) + time.sleep(wait_time) + continue + + 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": + logger.debug(f"[API성공] POST {path} TR_ID={tr_id} status=200 rt_cd=0") + return r + if j.get("msg_cd") == "EGW00201" or "초과" in str(j.get("msg1", "")) or "과부하" in str(j.get("msg1", "")): + wait_time = 5 + (attempt * 1) + logger.warning( + f"⏳ API 과부하 (EGW00201) POST {path} TR_ID={tr_id} -> {wait_time}초 대기 후 재시도 " + f"({attempt+1}/{max_retries}) rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + time.sleep(wait_time) + continue + # EGW00123: 기간이 만료된 token → 캐시 삭제 후 새 토큰 발급, 1회만 재시도 + if _is_token_expired_response(j) and not token_refreshed: + token_refreshed = True + _invalidate_kis_token_cache(self.mock) + self._auth() + logger.info("한투 토큰 만료(EGW00123) 감지 → 캐시 삭제 후 재발급, POST 재시도") + time.sleep(0.5) + continue + try: + body_preview = (r.text or "")[:500] + except Exception: + body_preview = "" + logger.warning( + f"[API실패] POST {path} TR_ID={tr_id} status={r.status_code} " + f"body={body_preview}" + ) + return r + except requests.exceptions.RequestException as e: + if attempt < max_retries - 1: + wait_time = (2 ** attempt) + random.uniform(0.5, 1.5) + logger.warning(f"⚠️ 네트워크 에러 -> {wait_time:.1f}초 대기 후 재시도: {e}") + time.sleep(wait_time) + else: + logger.error(f"❌ POST 요청 실패 ({path}): {e}") + return r + + def inquire_price(self, stock_code): + """ + 주식 현재가 시세 조회 [v1_국내주식-008] (단건). + output: stck_prpr(현재가) ✅, stck_oprc(시가), stck_hgpr(고가), stck_lwpr(저가) 등 당일 OHLC 포함. + 실패 시 오류코드(rt_cd, msg_cd, msg1) 로깅. + """ + # 짧은 TTL 캐시로 동일 종목 반복 호출 시 레이트리밋·지연 완화 + cache_ttl = get_env_float("KIS_PRICE_CACHE_TTL_SEC", 1.0) + if cache_ttl > 0: + now_ts = time.time() + cached = self._price_cache.get(stock_code) + if cached: + ts, output = cached + if now_ts - ts <= cache_ttl: + logger.debug(f"[현재가API-캐시히트] code={stock_code} ttl={cache_ttl}s") + return output + + path = "/uapi/domestic-stock/v1/quotations/inquire-price" + tr_id = "FHKST01010100" + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": stock_code} + logger.debug(f"[현재가API] 호출 code={stock_code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + try: + body_preview = (r.text or "")[:300] + except Exception: + body_preview = "" + logger.warning( + f"[현재가API] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} " + f"status={r.status_code} body={body_preview}" + ) + return None + try: + j = r.json() + except Exception as e: + logger.warning( + f"[현재가API] JSON 파싱 실패 code={stock_code} path={path} TR_ID={tr_id} exception={e}" + ) + return None + if j.get("rt_cd") != "0": + logger.warning( + f"[현재가API] 오류 code={stock_code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return None + output = j.get("output") + if cache_ttl > 0 and output is not None: + try: + self._price_cache[stock_code] = (time.time(), output) + except Exception: + pass + return output + + def inquire_multprice(self, stock_codes: List[str], max_per_call: int = 20): + """ + 다중 종목 현재가 조회 [intstock-multprice] + - 한투 API: /uapi/domestic-stock/v1/quotations/intstock-multprice + - 성공 시 {종목코드: output딕셔너리} 반환, 실패 시 None (오류 시 rt_cd/msg_cd/msg1 로깅) + - TR_ID: FHKST01010600 + - ⚠️ 배치 응답에는 stck_oprc(시가)/stck_hgpr(고가)/stck_lwpr(저가)가 없을 수 있음(API 스펙). + 시가·고가·저가 필요 시 단건 inquire_price() 사용. + """ + if not stock_codes: + return None + codes = list(stock_codes)[: max_per_call * 10] + result = {} + for i in range(0, len(codes), max_per_call): + chunk = codes[i : i + max_per_call] + iscd = ",".join(chunk) + path = "/uapi/domestic-stock/v1/quotations/intstock-multprice" + tr_id = "FHKST01010600" + params = {"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": iscd} + r = self._get(path, tr_id, params) + if r.status_code != 200: + try: + body_preview = (r.text or "")[:300] + except Exception: + body_preview = "" + logger.warning( + f"[다중시세API] HTTP 실패 status={r.status_code} body={body_preview}" + ) + continue + try: + j = r.json() + except Exception as e: + logger.warning(f"[다중시세API] JSON 파싱 실패 exception={e}") + continue + if j.get("rt_cd") != "0": + logger.warning( + f"[다중시세API] 오류 rt_cd={j.get('rt_cd')} " + f"msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + continue + out = j.get("output") + if out is None: + continue + if isinstance(out, list): + for item in out: + if isinstance(item, dict): + code = ( + item.get("stck_shrn_iscd") + or item.get("rsym") + or item.get("FID_INPUT_ISCD") + or item.get("mksc_shrn_iscd") + ) + if code: + result[code] = item + elif isinstance(out, dict): + code = ( + out.get("stck_shrn_iscd") + or out.get("rsym") + or out.get("FID_INPUT_ISCD") + ) + if code: + result[code] = out + time.sleep(random.uniform(0.2, 0.5)) + return result if result else None + + def inquire_prices_batch(self, stock_codes: List[str]): + """ + 다중 종목 현재가 일괄 조회 + - intstock-multprice API 우선 시도 후, 실패 시 순차 조회(inquire_price)로 fallback + - 순차 조회 시 종목당 0.3~0.6초 딜레이 + - 배치 응답에는 stck_prpr(현재가)는 있으나, stck_oprc/stck_hgpr/stck_lwpr 는 없을 수 있음. + 시가·고가·저가 필요 시 배치 대신 종목별 inquire_price() 사용(개미털기 스캔은 이미 단건 사용). + """ + if not stock_codes: + return {} + multi = self.inquire_multprice(stock_codes) + if multi: + return multi + result = {} + for code in stock_codes: + try: + price_data = self.inquire_price(code) + if price_data: + result[code] = price_data + time.sleep(random.uniform(0.3, 0.6)) + except Exception as e: + logger.warning(f"종목 조회 실패({code}) exception={e!r}") + continue + return result + + def get_account_balance(self): + """계좌 잔고 조회 [v1_국내주식-010]. 모의/실전에 따라 TR ID 분기 (EGW2004 방지).""" + if self.mock: + tr_id = "VTTC8434R" + else: + tr_id = "TTTC8434R" + params = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "01", # 01: 예수금/잔고 요약 (output2에 예수금) - 블로그·한투 문서 기준 + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "00", # 00: 조회 (블로그 기준) + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + } + try: + logger.info(f"💵 [예수금] 잔고 조회 요청: TR={tr_id}, CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}") + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance", + tr_id, + params, + ) + if r.status_code != 200: + logger.warning( + f"💵 [예수금] 잔고 API HTTP 오류: status={r.status_code}, body={getattr(r, 'text', '')[:200]} | " + f"TR={tr_id} (모의={self.mock}), CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}. " + f"EGW2004 시 모의면 VTTC8434R/실전이면 TTTC8434R 확인" + ) + return None + j = r.json() + if j.get("rt_cd") != "0": + msg1 = (j.get("msg1") or "")[:150] + msg_cd = j.get("msg_cd", "") + logger.error( + f"💵 [예수금] 잔고 API 응답 오류: rt_cd={j.get('rt_cd')}, msg_cd={msg_cd}, msg1={msg1} | " + f"요청 파라미터: TR={tr_id}, CANO={self.acc_no}({len(self.acc_no)}자리), " + f"ACNT_PRDT_CD={self.acc_code}({len(self.acc_code)}자리), 모의={self.mock} | " + f"전체 응답: {j}" + ) + if "OPSQ2000" in str(msg_cd) or "INVALID_CHECK_ACNO" in msg1: + logger.error( + "💵 [예수금] OPSQ2000 = 계좌번호 검증 실패. " + f"모의투자 서버({self.base_url})에 계좌번호 CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}가 등록되어 있는지 확인. " + f"한투 모의투자 앱/웹에서 계좌번호 확인 필요. DB의 KIS_ACCOUNT_NO_MOCK/KIS_ACCOUNT_CODE_MOCK 값 확인." + ) + return None + logger.debug(f"💵 [예수금] 잔고 조회 성공: output2 keys={list(j.get('output2', [{}])[0].keys()) if isinstance(j.get('output2'), list) and j.get('output2') else []}") + return j + except Exception as e: + logger.error(f"💵 [예수금] 잔고 조회 예외: {e} | CANO={self.acc_no}, ACNT_PRDT_CD={self.acc_code}, 모의={self.mock}") + return None + + def get_account_evaluation(self): + """계좌 평가 잔고 조회 [v1_국내주식-011]. 모의=VTTC8494R, 실전=TTTC8494R.""" + if self.mock: + tr_id = "VTTC8494R" + else: + tr_id = "TTTC8494R" + try: + logger.info(tr_id) + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-balance-rlz-pl", + tr_id, + { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "AFHR_FLPR_YN": "N", + "OFL_YN": "", + "INQR_DVSN": "01", + "UNPR_DVSN": "01", + "FUND_STTL_ICLD_YN": "N", + "FNCG_AMT_AUTO_RDPT_YN": "N", + "PRCS_DVSN": "01", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"계좌 평가 조회 실패: {e}") + return None + + def get_order_history(self, start_date=None, end_date=None): + """주문 체결 내역 조회 [v1_국내주식-012] (모의=VTTC8001R, 실전=TTTC8001R)""" + try: + if self.mock: + tr_id = "VTTC8001R" + else: + tr_id = "TTTC8001R" + if not start_date: + start_date = dt.now().strftime("%Y%m%d") + if not end_date: + end_date = dt.now().strftime("%Y%m%d") + + r = self._get( + "/uapi/domestic-stock/v1/trading/inquire-daily-ccld", + tr_id, + { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "INQR_STRT_DT": start_date, + "INQR_END_DT": end_date, + "SLL_BUY_DVSN_CD": "00", # 00:전체 + "INQR_DVSN": "00", # 00:역순 + "PDNO": "", + "CCLD_DVSN": "00", # 00:전체 + "ORD_GNO_BRNO": "", + "ODNO": "", + "INQR_DVSN_3": "00", + "INQR_DVSN_1": "", + "CTX_AREA_FK100": "", + "CTX_AREA_NK100": "", + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j + except Exception as e: + logger.error(f"주문 내역 조회 실패: {e}") + return None + + def get_execution_by_odno(self, odno, code=None, wait_sec=2): + """ + 주문번호(ODNO)로 당일 체결 내역 조회. 시장가 매수 후 실제 체결가/체결수량 확인용. + [v1_국내주식-012] inquire-daily-ccld 응답 output2에서 해당 주문 찾아 체결정보 반환. + + Args: + odno: 주문번호 (buy_order 성공 시 반환값) + code: 종목코드 (선택, 일치 행 필터용) + wait_sec: 조회 전 대기 초 (체결 반영 지연 고려, 기본 2초) + + Returns: + 성공 시 {"filled_qty": int, "avg_price": float}, 미체결/조회실패 시 None + """ + if not odno: + return None + time.sleep(max(0, wait_sec)) + try: + j = self.get_order_history() + if not j: + return None + out2 = j.get("output2", []) + if isinstance(out2, dict): + out2 = [out2] + for row in out2: + row_odno = str(row.get("ODNO") or row.get("ord_no") or "").strip() + row_pdno = str(row.get("PDNO") or row.get("pdno") or "").strip() + if row_odno != str(odno).strip(): + continue + if code and row_pdno and row_pdno != str(code).strip(): + continue + # 총체결수량 / 체결평균가 (한투 문서 필드명 다양하므로 후보 나열) + filled = row.get("tot_ccld_qty") or row.get("TOT_CCLD_QTY") or row.get("ccld_qty") or row.get("ord_qty") or row.get("ORD_QTY") + avg_pr = row.get("avg_prvs") or row.get("AVG_PRVS") or row.get("rjct_avg_prvs") or row.get("RJCT_AVG_PRVS") or row.get("ord_unpr") or row.get("ORD_UNPR") + if filled is not None and avg_pr is not None: + qty = int(float(str(filled).replace(",", ""))) + price = float(str(avg_pr).replace(",", "")) + if qty > 0 and price > 0: + logger.debug(f"[체결조회] ODNO={odno} -> 체결수량={qty}, 체결평균가={price:,.0f}") + return {"filled_qty": qty, "avg_price": price} + logger.debug(f"[체결조회] ODNO={odno} 해당 주문 체결 내역 없음 (output2 건수={len(out2)})") + return None + except Exception as e: + logger.warning(f"주문번호 체결 조회 예외 ODNO={odno}: {e}") + return None + + def get_volume_surge_stocks(self, market="J", min_volume_rate="50", limit=50): + """거래량 급증 종목 조회 [v1_국내주식-023]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + { + "FID_COND_MRKT_DIV_CODE": market, + "FID_INPUT_ISCD": "", + "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + }, + ) + # 실제로는 거래량 급증 API를 사용해야 하지만, 여기서는 예시로 현재가 조회 활용 + # 실제 구현 시: /uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice 사용 + return [] + except Exception as e: + logger.error(f"거래량 급증 종목 조회 실패: {e}") + return [] + + def get_top_price_movers(self, market="J", sort_type="1", limit=50): + """등락률 상위 종목 조회 [v1_국내주식-027]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice", + "FHKST03010200", + { + "FID_COND_MRKT_DIV_CODE": market, + "FID_INPUT_ISCD": "", + "FID_INPUT_HOUR_1": dt.now().strftime("%Y%m%d"), + "FID_INPUT_HOUR_2": dt.now().strftime("%Y%m%d"), + "FID_PW_DATA_INCU_YN": "Y", + }, + ) + # 실제 구현 필요 + return [] + except Exception as e: + logger.error(f"등락률 상위 조회 실패: {e}") + return [] + + def get_investor_trend(self, stock_code, days=5): + """외국인/기관 매매 동향 조회""" + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + tr_id = "FHKST03010100" + try: + # 일봉 데이터에서 외국인/기관 정보 추출 + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=days + 10) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": "D", + "FID_ORG_ADJ_PRC": "1", + } + logger.debug(f"[투자자동향] 호출 code={stock_code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[투자자동향] HTTP 실패 code={stock_code} path={path} TR_ID={tr_id} status={r.status_code}") + return None + + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[투자자동향] 오류 code={stock_code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return None + + out2 = j.get("output2", []) + if not out2: + return None + + # 최근 N일 외국인/기관 순매수 합계 + foreign_sum = 0 + org_sum = 0 + + for item in out2[:days]: + try: + foreign_raw = item.get("frgn_ntby_qty") or item.get("frgn_ntby_shnu") or "0" + foreign_net = int(float(str(foreign_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(foreign_raw).startswith("-"): + foreign_net = -foreign_net + + org_raw = item.get("orgn_ntby_qty") or "0" + org_net = int(float(str(org_raw).replace(",", "").replace("+", "").replace("-", ""))) + if str(org_raw).startswith("-"): + org_net = -org_net + + foreign_sum += foreign_net + org_sum += org_net + except: + continue + + return { + "foreign_net_buy": foreign_sum, + "org_net_buy": org_sum, + "total_net_buy": foreign_sum + org_sum, + } + except Exception as e: + logger.error(f"외국인/기관 동향 조회 실패({stock_code}): {e}") + return None + + def get_daily_chart(self, code, limit=10): + """일봉 차트 조회 [v1_국내주식-017] - 거래대금(대/중/소형) 계산용""" + path = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice" + tr_id = "FHKST03010100" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=limit + 30) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_DATE_1": start_ymd, + "FID_INPUT_DATE_2": end_ymd, + "FID_PERIOD_DIV_CODE": "D", + "FID_ORG_ADJ_PRC": "1", + } + logger.debug(f"[일봉차트] 호출 code={code} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[일봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[일봉차트] 오류 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return pd.DataFrame() + data = j.get("output2", []) + if not data: + return pd.DataFrame() + rows = [] + for item in data: + try: + rows.append({ + "date": item.get("stck_bsop_date", ""), + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except Exception: + continue + if not rows: + return pd.DataFrame() + df = pd.DataFrame(rows) + df = df.sort_values("date").reset_index(drop=True) + return df.tail(limit) + except Exception as e: + logger.error(f"일봉 조회 실패({code}): {e}") + return pd.DataFrame() + + def buy_order(self, code, qty, price=0, order_type="01"): + """ + 매수 주문 (모의=VTTC0802U, 실전=TTTC0802U) + + Args: + code: 종목코드 + qty: 수량 + price: 가격 (0이면 시장가) + order_type: 주문구분 + - "01": 시장가 + - "00": 지정가 + - "05": 조건부지정가 + - "06": 최유리지정가 + - "07": 최우선지정가 + - "10": 보통(IOC) + - "13": 시장가(IOC) + - "16": 최유리(IOC) + - "20": 보통(FOK) + - "23": 시장가(FOK) + - "26": 최유리(FOK) + """ + try: + if self.mock: + tr_id = "VTTC0802U" + else: + tr_id = "TTTC0802U" + if price > 0: + ord_unpr = str(price) + else: + ord_unpr = "0" + path = "/uapi/domestic-stock/v1/trading/order-cash" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": ord_unpr, + } + logger.debug(f"[매수주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}") + r = self._post(path, tr_id, body, use_hashkey=True) + if r.status_code != 200: + logger.error(f"[매수주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + self._last_order_msg_cd = None + self._last_order_msg1 = None + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매수 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + # 체결 확인용으로 주문번호 반환 (실패 시 False) + return ord_no if ord_no else True + else: + # 매매불가 등 실패 시 bot에서 당일 제외용으로 구분할 수 있도록 저장 + self._last_order_msg_cd = j.get("msg_cd", "") + self._last_order_msg1 = str(j.get("msg1", "") or "") + logger.error( + f"[매수주문] 실패 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={self._last_order_msg_cd} msg1={self._last_order_msg1}" + ) + return False + except Exception as e: + self._last_order_msg_cd = None + self._last_order_msg1 = None + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def buy_market_order(self, code, qty): + """ + 시장가 매수 주문. + - 모의투자: FOK/IOC 미지원이므로 무조건 "01" 일반 시장가. + - 실전: USE_MARKET_IOC=true면 "13" 시장가 IOC, false면 "01" 일반 시장가. + """ + if self.mock: + order_type = "01" + else: + use_ioc = get_env_from_db("USE_MARKET_IOC", "true").strip().lower() in ("true", "1", "yes") + order_type = "13" if use_ioc else "01" + return self.buy_order(code, qty, price=0, order_type=order_type) + + def sell_order(self, code, qty, price=0, order_type="01"): + """ + 매도 주문 (모의=VTTC0801U, 실전=TTTC0801U) + + Args: + code: 종목코드 + qty: 수량 + price: 가격 (0이면 시장가) + order_type: 주문구분 (buy_order와 동일) + """ + try: + if self.mock: + tr_id = "VTTC0801U" + else: + tr_id = "TTTC0801U" + if price > 0: + ord_unpr = str(price) + else: + ord_unpr = "0" + path = "/uapi/domestic-stock/v1/trading/order-cash" + body = { + "CANO": self.acc_no, + "ACNT_PRDT_CD": self.acc_code, + "PDNO": code, + "ORD_DVSN": order_type, + "ORD_QTY": str(qty), + "ORD_UNPR": ord_unpr, + } + logger.debug(f"[매도주문] 호출 code={code} qty={qty} price={price} path={path} TR_ID={tr_id}") + r = self._post(path, tr_id, body, use_hashkey=True) + if r.status_code != 200: + logger.error(f"[매도주문] HTTP 에러 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return False + j = r.json() + if j.get("rt_cd") == "0": + self._last_sell_msg_cd = None + self._last_sell_msg1 = None + ord_no = j.get("output", {}).get("ODNO", "") + logger.info(f"✅ 매도 주문 성공: {code} {qty}주 (주문번호: {ord_no})") + return True + else: + # execute_sell 에서 실패 원인(영업일 아님 등) 구분할 수 있도록 저장 + self._last_sell_msg_cd = j.get("msg_cd", "") + self._last_sell_msg1 = str(j.get("msg1", "") or "") + logger.error( + f"[매도주문] 실패 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={self._last_sell_msg_cd} msg1={self._last_sell_msg1}" + ) + return False + except Exception as e: + self._last_sell_msg_cd = None + self._last_sell_msg1 = None + logger.error(f"매도 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문 (간편 메서드)""" + return self.sell_order(code, qty, price=0, order_type="01") + + def get_minute_chart(self, code, period="3", limit=100): + """분봉 차트 조회 [v1_국내주식-017]""" + path = "/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice" + tr_id = "FHKST03010200" + try: + end_dt = dt.now() + start_dt = end_dt - datetime.timedelta(days=1) + start_ymd = start_dt.strftime("%Y%m%d") + end_ymd = end_dt.strftime("%Y%m%d") + + # 분봉 코드: 1분=1, 3분=3, 5분=5, 10분=10, 30분=30, 60분=60 + period_map = {"1": "1", "3": "3", "5": "5", "10": "10", "30": "30", "60": "60"} + period_code = period_map.get(str(period), "3") + + params = { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": code, + "FID_INPUT_HOUR_1": start_ymd, + "FID_INPUT_HOUR_2": end_ymd, + "FID_PW_DATA_INCU_YN": "Y", + "FID_ETC_CLS_CODE": "", # 기타분류코드 (필수 파라미터, 빈 값 가능) + } + logger.debug(f"[분봉차트] 호출 code={code} period={period} path={path} TR_ID={tr_id}") + r = self._get(path, tr_id, params) + if r.status_code != 200: + logger.warning(f"[분봉차트] HTTP 실패 code={code} path={path} TR_ID={tr_id} status={r.status_code}") + return pd.DataFrame() + j = r.json() + if j.get("rt_cd") != "0": + logger.warning( + f"[분봉차트] 오류 code={code} path={path} TR_ID={tr_id} " + f"rt_cd={j.get('rt_cd')} msg_cd={j.get('msg_cd')} msg1={j.get('msg1')}" + ) + return pd.DataFrame() + + data = j.get("output2", []) + if not data: + return pd.DataFrame() + + rows = [] + for item in data: + try: + # 정렬용: 영업일자+체결시각(있으면) → 마지막 봉이 실제 최신봉이 되도록 + date_str = str(item.get("stck_bsop_date", "") or "") + time_str = str(item.get("stck_cntg_hour", "") or item.get("cntg_hour", "") or "000000") + sort_key = date_str + time_str + rows.append({ + "time": sort_key, + "open": abs(float(item.get("stck_oprc", 0))), + "high": abs(float(item.get("stck_hgpr", 0))), + "low": abs(float(item.get("stck_lwpr", 0))), + "close": abs(float(item.get("stck_clpr", 0))), + "volume": int(item.get("acml_vol", 0)), + }) + except Exception: + continue + + if not rows: + return pd.DataFrame() + + df = pd.DataFrame(rows) + df = df.sort_values("time").reset_index(drop=True) + + # 기술적 지표 추가 + # RSI 기간: DB/env의 RSI_PERIOD 로 조절 (기본 14, 단타/스캘핑 시 3·5 권장) + # RSI 수학적 안정화를 위해 호출 측에서 limit≥100 이상 요청하는 것이 전제 + rsi_period = get_env_int("RSI_PERIOD", 14) + if len(df) >= rsi_period: + delta = df["close"].diff(1) + gain = delta.where(delta > 0, 0).rolling(window=rsi_period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=rsi_period).mean() + rs = gain / loss.replace(0, float("nan")) + df["RSI"] = 100 - (100 / (1 + rs)) + + if len(df) >= 20: + df["MA20"] = df["close"].rolling(window=20).mean() + # MA5: check_buy_signal_tail_catch 에서 ma5_gap_pct 계산에 사용 (없으면 None으로 처리됨) + if len(df) >= 5: + df["MA5"] = df["close"].rolling(window=5).mean() + + return df.tail(limit) + except Exception as e: + logger.error(f"분봉 조회 실패({code}): {e}") + return pd.DataFrame() + + def get_orderbook(self, stock_code): + """호가 조회 [v1_국내주식-009]""" + try: + r = self._get( + "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", + "FHKST01010200", + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": stock_code, + }, + ) + if r.status_code != 200: + return None + j = r.json() + if j.get("rt_cd") != "0": + return None + return j.get("output") + except Exception as e: + logger.error(f"호가 조회 실패({stock_code}): {e}") + return None + + # ============================================================ + # 순위분석 API (키움 봇과 동일한 로직 + 레버리지/스팩/ETN 제외 옵션) + # ============================================================ + + @staticmethod + def _is_valid_stock(name: str, code: str) -> bool: + """ + 종목 필터링 (키움 kiwoom_trader_ver2와 동일, ETF는 포함) + - 스팩, ETN, 우선주, 레버리지, 인버스 등만 제외 (ETF는 위험도 낮아 포함) + """ + if not code or len(code) != 6 or not code.isdigit(): + return False + name = (name or "").strip() + exclude = [ + "스팩", "SPAC", "ETN", "W", "ELW", "채권", + "레버리지", "인버스", "곱버스", "선물", "콜", "풋", + "2X", "3X", "합성", "H", "B", + ] + # ETF는 exclude 목록에 없음 → 일반 주식·ETF 모두 통과 + if any(k in name for k in exclude): + return False + if name.endswith("우") or name.endswith("우B"): + return False + return True + + def _filter_rank_by_valid_stock(self, rank_list: list) -> list: + """랭크 API 응답 리스트에서 스팩/ETN/레버리지 등 제외 (키움 옵션과 동일)""" + if not rank_list: + return [] + filtered = [] + for item in rank_list: + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + name = (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip() + if self._is_valid_stock(name, code): + filtered.append(item) + return filtered + + def _fetch_volume_rank_paged( + self, + market: str, + blng_cls_code: str, + limit: int, + exclude_spec_etn_leverage: bool, + ) -> list: + """ + 거래량순위 API 1회 조회 (한투 volume-rank API는 다음 페이지 tr_cont 미지원 → 1회만 호출). + """ + path = "/uapi/domestic-stock/v1/quotations/volume-rank" + tr_id = "FHPST01710000" + params = { + "FID_COND_MRKT_DIV_CODE": market, + "FID_COND_SCR_DIV_CODE": "20171", + "FID_INPUT_ISCD": "0000", + "FID_DIV_CLS_CODE": "0", + "FID_BLNG_CLS_CODE": blng_cls_code, + "FID_TRGT_CLS_CODE": "111111111", + "FID_TRGT_EXLS_CLS_CODE": "0000000000", + "FID_INPUT_PRICE_1": "0", + "FID_INPUT_PRICE_2": "0", + "FID_VOL_CNT": "0", + "FID_INPUT_DATE_1": "", + } + try: + time.sleep(0.5) + r = self._get(path, tr_id, params, tr_cont=None) + if r.status_code != 200: + return [] + j = r.json() + if j.get("rt_cd") != "0": + return [] + output = j.get("output", []) + if exclude_spec_etn_leverage: + output = self._filter_rank_by_valid_stock(output) + logger.info(f" 📡 [순위API] 수신 {len(output)}건 (다음페이지 미지원 → 1회만 호출)") + return output[:limit] + except Exception as e: + logger.debug(f"거래량순위 조회 실패: {e}") + return [] + + def get_volume_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """ + 거래량순위 조회 [v1_국내주식-047] (1회 호출, API가 반환한 건수만큼 수집) + """ + try: + output = self._fetch_volume_rank_paged( + market=market, + blng_cls_code="0", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + return output + except Exception as e: + logger.debug(f"거래량순위 조회 실패: {e}") + return [] + + def get_price_change_rank( + self, + market: str = "J", + sort_type: str = "1", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """ + 등락률순위 조회 (동일 volume-rank API, FID_BLNG_CLS_CODE로 등락 구분) + sort_type: "1"=상승률 상위, "2"=하락률 상위(낙폭 큰 종목, N자 망치봉 스캔에 유리) + """ + # 한투 volume-rank API: 4=등락률(상승), 5=등락률(하락). 미지원 시 빈값/에러 가능. + blng = "5" if sort_type == "2" else "4" + try: + out = self._fetch_volume_rank_paged( + market=market, + blng_cls_code=blng, + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + if out: + return out + except Exception as e: + logger.debug(f"등락률순위(blng={blng}) 조회 실패: {e}") + # API가 4/5 미지원이면 기존처럼 거래량순위로 fallback (상승 위주 후보 확보) + if sort_type != "2": + return self.get_volume_rank(market=market, limit=limit, exclude_spec_etn_leverage=exclude_spec_etn_leverage) + return [] + + def get_price_decline_rank( + self, + market: str = "J", + limit: int = 100, + exclude_spec_etn_leverage: bool = True, + ): + """하락률 순위(낙폭 큰 종목) 조회. N자 망치봉/개미털기 스캔 유니버스 확대용.""" + return self.get_price_change_rank( + market=market, + sort_type="2", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + + def get_trading_value_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """거래대금순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=3""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="3", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"거래대금순위 조회 실패: {e}") + return [] + + def get_turnover_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """회전율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=2""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="2", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"회전율순위 조회 실패: {e}") + return [] + + def get_volume_growth_rank( + self, + market: str = "J", + limit: int = 50, + exclude_spec_etn_leverage: bool = True, + ): + """거래증가율순위 조회 (연속 조회로 limit건까지). FID_BLNG_CLS_CODE=1""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="1", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"거래증가율순위 조회 실패: {e}") + return [] + + def get_execution_strength_rank( + self, + market: str = "J", + limit: int = 200, + exclude_spec_etn_leverage: bool = True, + ): + """체결강도 상위 순위 조회 (FHPST01710000, FID_BLNG_CLS_CODE=6). 매수세 강한 종목 필터용.""" + try: + return self._fetch_volume_rank_paged( + market=market, + blng_cls_code="6", + limit=limit, + exclude_spec_etn_leverage=exclude_spec_etn_leverage, + ) + except Exception as e: + logger.debug(f"체결강도순위 조회 실패: {e}") + return [] + + def get_execution_strength_map(self, market: str = "J", limit: int = 200): + """ + 체결강도 상위 API 조회 후 종목코드 -> 체결강도 값 매핑 반환. + output 필드: cntr_str(체결강도) 등 한투 문서 기준으로 파싱. 미제공 시 0. + """ + strength_map = {} + try: + rows = self.get_execution_strength_rank(market=market, limit=limit, exclude_spec_etn_leverage=True) + for item in (rows or []): + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + if not code or len(code) != 6: + continue + # 한투 volume-rank 체결강도: cntr_str 또는 유사 필드 (문서 확인 후 조정) + raw = item.get("cntr_str") or item.get("exec_str") or item.get("strg_rt") or item.get("prdy_ctrt") or "" + try: + strength = float(str(raw).replace(",", "").strip()) if raw else 0 + except (ValueError, TypeError): + strength = 0 + strength_map[code] = strength + except Exception as e: + logger.debug(f"체결강도 맵 조회 실패: {e}") + return strength_map + + +# ============================================================ +# Mattermost 봇 클래스 +# ============================================================ +class MattermostBot: + """Mattermost 알림 봇""" + 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 MM_CONFIG_FILE.exists(): + with open(MM_CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f).get("channels", {}) + return {} + 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"❌ '{channel_alias}' 채널 ID 없음") + return False + + payload = {"channel_id": channel_id, "message": message} + try: + res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3) + res.raise_for_status() + return True + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + return False + + +def _save_ai_recommendations_from_text(db_instance, analysis_text: str): + """AI 분석문에서 'KEY=값' 추천 줄만 추출해 DB에 저장 (!적용 시 사용).""" + if not analysis_text or not db_instance: + return + valid_keys = set(ENV_CONFIG_KEYS) + lines = [] + 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: + lines.append(f"{m.group(1)}={m.group(2).strip()}") + if lines: + db_instance.set_last_ai_recommendations("\n".join(lines)) + + +# ============================================================ +# 단타 트레이딩 봇 +# ============================================================ +class ShortTradingBot: + """단타용 트레이딩 봇 - 개미털기(눌림목) 전략""" + def __init__(self): + self.db = db + self.client = KISClient() + + # Mattermost 초기화 + self.mm = MattermostBot() + # 단타 봇 전용 채널(alias) 우선 사용, 없으면 기본 채널 사용 + self.mm_channel = MM_CHANNEL_SHORT + + # ML 예측 초기화 (선택적) + self.ml_predictor = None + if ML_AVAILABLE: + try: + self.ml_predictor = MLPredictor() # MariaDB 내부 연결 + if self.ml_predictor.should_retrain(): + self.ml_predictor.train_model(retrain=True) + except Exception as e: + logger.warning(f"⚠️ ML 예측 초기화 실패: {e}") + + # RiskManager 초기화 (뼈대만 생성 → reload_config에서 DB 값으로 실시간 갱신) + self.risk_mgr = None + if RISK_MANAGER_AVAILABLE: + self.risk_mgr = RiskManager( + risk_pct_per_trade=get_env_float("RISK_PCT_PER_TRADE", 0.01), + max_position_pct=get_env_float("MAX_POSITION_PCT", 0.15), + min_position_amount=get_env_int("MIN_POSITION_AMOUNT", 50000), + use_kelly=get_env_bool("USE_KELLY_FORMULA", True), + kelly_multiplier=get_env_float("KELLY_MULTIPLIER", 0.25), + slot_base_amount_cap=get_env_int("SLOT_BASE_AMOUNT_CAP", 0), + # ── 무조건 깔고 가는 MAX_LOSS 기반 투자 상한 ───────────── + # ATR 계산 결과가 아무리 커도 이 상한 초과 불가 + max_loss_per_trade_krw=get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000), + stop_loss_pct=get_env_float("STOP_LOSS_PCT", -0.03), + # ── 사이즈 클래스별 비율 (DB에서 주입) ─────────────────── + size_small_ratio=get_env_float("SIZE_CLASS_SMALL_RATIO", 0.70), + size_mid_ratio=get_env_float("SIZE_CLASS_MID_RATIO", 0.85), + ) + logger.info("✅ RiskManager 뼈대 생성 완료") + else: + logger.warning("⚠️ RiskManager 미사용: 고정 슬롯 금액 방식으로 폴백") + + # ★ [실시간 리로드] DB에서 최신 설정값을 불러와 봇·RiskManager에 반영 (재시작 없이 적용) + self.reload_config() + + # 리포트 플래그 (reload_config 이후 유지) + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.ai_report_sent = False + + # 자산 추적 + self.today_date = dt.now().strftime("%Y-%m-%d") + self.start_day_asset = 0 + self.current_total_asset = 0 + self.current_cash = 0 + self.d2_excc_amt = 0 # D+2 예수금 (output2 prvs_rcdl_excc_amt) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + + # DB에서 활성 트레이드 로드 (단타만: SHORT_% - 늘림목과 섞이지 않도록) + self.holdings = {} + # 당일 매매불가로 확인된 종목 (같은 종목 반복 주문 방지 → 다음 후보로 넘어감) + self.untradable_skip_set = set() + # 최근 매도 종목 쿨다운 캐시 {code: 매도_timestamp} + # 매도 직후 같은 종목을 즉시 재매수하는 반복매매 루프 방지. + # 쿨다운 기간은 REENTRY_COOLDOWN_SEC(기본 5분)으로 조정. + self.recently_sold: dict = {} + # 매도 실패 백오프 캐시 {code: until_timestamp} + # "영업일이 아닙니다" 등 일시적 API 거부 시 재시도 방지. + # 재시도 대기 시간은 SELL_FAILURE_BACKOFF_SEC(기본 1800초=30분) 으로 조정. + self._sell_backoff: dict = {} + active_trades = self.db.get_active_trades(strategy_prefix="SHORT") + for code, trade in active_trades.items(): + self.holdings[code] = { + "buy_price": trade.get("avg_buy_price", 0), + "qty": trade.get("current_qty", 0), + "buy_time": trade.get("buy_date", dt.now().strftime("%Y-%m-%d %H:%M:%S")), + "name": trade.get("name", code), + } + + # ★ 계좌 잔고 API와 동기화 (폰/타 앱으로 산 종목 반영) + balance = self.client.get_account_balance() + if balance is not None: + self._sync_holdings_from_balance(balance) + self._update_assets(balance=balance) # 이미 받은 잔고로 자산 갱신 (API 1회만) + else: + self._update_assets() + + # 비동기 태스크 관리 + self._universe_task = None + self._report_task = None + self._asset_task = None + self.is_first_run = True + + # ── WebSocket + CandleAggregator 초기화 ────────────────────────────── + # 틱 수신 → 3분봉 in-memory 집계 → REST 폴링(get_minute_chart) 전면 대체 + # KISWebSocketPriceCache: 실시간 체결가 수신 (check_sell_signals 현재가) + # CandleAggregator : 3분봉 OHLCV·RSI 메모리 집계 (buy/ATR/RiskManager) + # start() 실패 시 is_active=False → REST fallback 자동 적용 + self.ws_cache: Optional["KISWebSocketPriceCache"] = None + self.candle_agg: Optional["CandleAggregator"] = None + self._init_websocket() + + # ── WebSocket + CandleAggregator 초기화 / 갭보정 / 구독 관리 ─────────── + + def _init_websocket(self): + """WebSocket 시작 → CandleAggregator(3분봉) 연결 → 종목 구독 → 갭 보정.""" + if not _KIS_WS_AVAILABLE: + logger.info("ℹ️ kis_ws 모듈 없음 → REST inquire_price / get_minute_chart 폴링 유지") + return + try: + self.ws_cache = KISWebSocketPriceCache( + app_key = self.client.app_key, + app_secret = self.client.app_secret, + is_mock = self.client.mock, + ) + # CandleAggregator: 3분봉 집계 (buy 타점·ATR·RiskManager 전용) + # 3분봉(주전략) + 15분봉 + 60분봉 — 추세 필터 / 다른 전략 확장용 + self.candle_agg = CandleAggregator(db=self.db, timeframes=[3, 15, 60]) + self.ws_cache.attach_candle_aggregator(self.candle_agg) + + ws_ok = self.ws_cache.start() + if not ws_ok: + logger.info("ℹ️ WebSocket 비활성 (모의 or 키 미설정) → REST fallback 유지") + self.ws_cache = None + self.candle_agg = None + return + + # 봇 재시작 시 보유 종목 즉시 구독 + for code in list(self.holdings.keys()): + self.ws_cache.subscribe(code) + + # 유니버스 후보 종목도 미리 구독 (매수 타점 체크 전 봉 데이터 확보) + candidates = self.db.get_target_candidates() + for c in candidates: + code = c.get("code") or c.get("stk_cd", "") + if code and code not in self.holdings: + self.ws_cache.subscribe(code) + + # ── 영구 구독 ETF: 시장 방향 필터용 (유니버스 변경과 무관하게 항상 유지) ── + perm_raw = get_env_from_db("PERMANENT_WS_CODES", "069500,229200") + self._permanent_ws_codes: set = { + c.strip() for c in str(perm_raw).split(",") if c.strip() + } + for code in sorted(self._permanent_ws_codes): + self.ws_cache.subscribe(code) + logger.info("📡 [영구구독] %s (시장방향 ETF)", code) + + logger.info( + "✅ WebSocket + CandleAggregator(3분봉) 활성 (구독 %d종목) " + "— get_minute_chart REST 폴링 대체", + len(self.ws_cache._subscribed), + ) + + # 시작 시 REST 갭 보정 (봉 버퍼 비어있는 경우 RSI 안정화) + self._fill_all_gaps() + + except Exception as _ws_e: + logger.warning("⚠️ WebSocket 초기화 예외(무시): %s", _ws_e) + self.ws_cache = None + self.candle_agg = None + + def _fill_all_gaps(self): + """ + 봇 시작·재접속 후 구독 중인 모든 종목의 분봉 갭을 보정. + RSI(14) 안정화를 위해 limit=120 사용. + + ▶ 키움 우선 전략: + - 키움 ka10080 은 1회 호출에 최대 900봉(≈6개월치) 제공 → 장 초반에도 즉시 봉 확보 가능 + - KIS get_minute_chart 는 당일봉만 제공 → 장 시작 직후 봉 부족 → 키움 우선 + - 키움 키 없으면 KIS fallback (3분봉만, 15/60분봉은 KIS 지원 안 함) + """ + if not self.candle_agg or not self.ws_cache: + return + limit = get_env_int("SHORT_GAP_FILL_LIMIT", 120) + with self.ws_cache._sub_lock: + codes = set(self.ws_cache._subscribed) + + # ── 키움 크레덴셜 조회 ──────────────────────────────────────── + kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db) + use_kiwoom = bool(kw_key and kw_secret) + logger.info( + "🔧 [갭보정] %d종목 분봉 로드 시작 (tfs=%s, limit=%d, kiwoom=%s)", + len(codes), self.candle_agg.timeframes, limit, "✅" if use_kiwoom else "❌→KIS fallback", + ) + + for code in sorted(codes): + for tf in self.candle_agg.timeframes: + df = None + # 키움 우선 (토큰은 23시간 캐시 → au10001 한도 방지) + if use_kiwoom: + try: + df = get_kiwoom_candles_df( + code, tf, kw_key, kw_secret, + is_mock=kw_mock, n=limit, + ) + except Exception as e: + logger.debug("키움 갭보정 실패 (%s %dM): %s", code, tf, e) + # KIS fallback: 당일봉만 → 3분봉에만 유효 + if (df is None or df.empty) and tf <= 3: + try: + df = self.client.get_minute_chart( + code, period=str(tf), limit=limit + ) + except Exception as e: + logger.debug("KIS 갭보정 실패 (%s %dM): %s", code, tf, e) + if df is not None and not df.empty: + self.candle_agg.fill_gap_from_rest(code, tf, df) + # 같은 종목 내 timeframe 전환: 짧은 딜레이 + time.sleep(random.uniform(0.2, 0.4)) + # 종목 간 딜레이 + time.sleep(random.uniform(0.3, 0.6)) + + def _sync_subscriptions(self, candidates: list): + """ + target_candidates DB 목록과 WS 구독 목록 동기화. + - 유니버스에서 빠진 종목(보유 중 아닌 것) → unsubscribe + RAM 정리 + - 신규 종목 → subscribe + 3분봉 갭 보정 (봉 버퍼 즉시 확보). + ※ 영구 구독 ETF(_permanent_ws_codes)는 절대 해제하지 않음 (시장 방향 필터용) + """ + if not self.ws_cache: + return + new_codes = {c.get("code") or c.get("stk_cd", "") for c in candidates if c} + new_codes.discard("") + # 현재 보유 종목은 매도 완료 전까지 반드시 유지 + new_codes |= set(self.holdings.keys()) + # 영구 구독 ETF는 유니버스와 무관하게 항상 유지 + new_codes |= getattr(self, '_permanent_ws_codes', set()) + + with self.ws_cache._sub_lock: + current_subs = set(self.ws_cache._subscribed) + + # ── 구독 해제: 유니버스에서 빠진 종목 ───────────────────────── + # 보유 중 종목은 매도 감시를 위해 구독 유지 + for code in sorted(current_subs - new_codes): + self.ws_cache.unsubscribe(code) + if self.candle_agg: + self.candle_agg.remove_code(code) + + # ── 신규 구독: 유니버스에 새로 들어온 종목 ───────────────────── + kw_key, kw_secret, kw_mock = _get_kiwoom_creds(self.db) + use_kiwoom = bool(kw_key and kw_secret) + for code in sorted(new_codes - current_subs): + self.ws_cache.subscribe(code) + # 신규 구독 즉시 갭 보정 (봉 없으면 매수 타점 체크 불가) — 키움 우선 + if not self.candle_agg: + continue + lim = get_env_int("SHORT_GAP_FILL_LIMIT", 120) + for tf in self.candle_agg.timeframes: + df = None + if use_kiwoom: + try: + df = get_kiwoom_candles_df( + code, tf, kw_key, kw_secret, + is_mock=kw_mock, n=lim, + ) + except Exception as e: + logger.debug("키움 신규갭보정 실패 (%s %dM): %s", code, tf, e) + if (df is None or df.empty) and tf <= 3: + try: + df = self.client.get_minute_chart( + code, period=str(tf), limit=lim + ) + except Exception as e: + logger.debug("KIS 신규갭보정 실패 (%s %dM): %s", code, tf, e) + if df is not None and not df.empty: + self.candle_agg.fill_gap_from_rest(code, tf, df) + # tf 간 딜레이 (차트 API, 토큰은 캐시 재사용) + time.sleep(random.uniform(0.2, 0.4)) + + def _get_candles_df(self, code: str, tf: int = 3, n: int = 20) -> Optional[pd.DataFrame]: + """ + CandleAggregator 메모리 봉 → DataFrame 변환 헬퍼. + + 확정봉(confirmed) + 진행봉(current, is_confirmed=0) 을 합쳐 + get_minute_chart 와 동일한 컬럼(open/high/low/close/volume/RSI/MA5/MA20)을 반환. + - 진행봉의 close = 현재가 (최신 틱) → 매수 타점 실시간 포착 + - CandleAggregator 미사용·데이터 부족 시 None 반환 → 호출부에서 REST fallback + + Args: + code : 종목코드 + tf : 봉 주기(분), 꼬리잡기 전략은 항상 3 + n : 반환할 최대 봉 수 (tail 기준) + """ + if not self.candle_agg: + return None + + # 확정봉: RSI_PERIOD(14)보다 넉넉하게 가져와 RSI 안정화 + rsi_period = get_env_int("RSI_PERIOD", 14) + fetch_n = max(n + rsi_period + 5, n + 20) + confirmed = self.candle_agg.get_candles(code, tf, fetch_n) + + if not confirmed: + return None + + rows = list(confirmed) + + # 진행 중인 봉(최신 틱 close) 을 tail 에 추가 → 실시간 캔들 패턴 포착 + current = self.candle_agg.get_current_candle(code, tf) + if current and current.get("open", 0) > 0 and current.get("close", 0) > 0: + rows.append(current) + + if len(rows) < 2: + return None + + df = pd.DataFrame(rows)[["open", "high", "low", "close", "volume"]].copy() + df = df.reset_index(drop=True) + + # 기술적 지표: get_minute_chart 와 동일 로직 + if len(df) >= rsi_period: + delta = df["close"].diff(1) + gain = delta.where(delta > 0, 0).rolling(window=rsi_period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=rsi_period).mean() + rs = gain / loss.replace(0, float("nan")) + df["RSI"] = 100 - (100 / (1 + rs)) + if len(df) >= 20: + df["MA20"] = df["close"].rolling(window=20).mean() + if len(df) >= 5: + df["MA5"] = df["close"].rolling(window=5).mean() + + return df.tail(n).reset_index(drop=True) + + # ── 설정 리로드 ───────────────────────────────────────────────────────── + + def reload_config(self): + """[실시간 리로드] DB(env) 설정을 봇에 반영. 메인 루프마다 호출 시 재시작 없이 적용.""" + # [손절/익절 설정] + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.04) + + # ★ STOP_LOSS_PCT 부호 안전장치: 양수(0.02)로 입력 시 자동으로 음수(-0.02)로 변환. + # 양수 값이 그대로 사용되면 profit_pct <= stop_loss_pct 조건이 손익분기 이상에서도 + # 참이 돼 칼손절이 수익 구간에서도 발동하는 심각한 버그가 발생함. + if self.stop_loss_pct > 0: + logger.warning( + "🚨 STOP_LOSS_PCT=%.4f 양수 감지 → 자동 부호 반전(%.4f). DB에 음수로 저장 권장 (!설정 STOP_LOSS_PCT=-%.4f)", + self.stop_loss_pct, -self.stop_loss_pct, self.stop_loss_pct, + ) + self.stop_loss_pct = -self.stop_loss_pct + + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05) + self.max_stocks = get_env_int("MAX_STOCKS", 3) + self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03) + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO_SHORT", 0.5) + + self.stop_atr_multiplier = get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5) + self.target_atr_multiplier = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 8.0) + self.min_hold_hours = get_env_float("MIN_HOLD_HOURS", 24.0) + + # [리스크 관리 설정] + self.risk_pct_per_trade = get_env_float("RISK_PCT_PER_TRADE", 0.01) + self.kelly_multiplier = get_env_float("KELLY_MULTIPLIER", 0.25) + self.max_position_pct = get_env_float("MAX_POSITION_PCT", 0.15) + self.min_position_amount = get_env_int("MIN_POSITION_AMOUNT", 50000) + use_kelly = get_env_bool("USE_KELLY_FORMULA", True) + + if self.risk_mgr is not None: + self.risk_mgr.risk_pct = self.risk_pct_per_trade + self.risk_mgr.max_pos_pct = self.max_position_pct + self.risk_mgr.min_amount = self.min_position_amount + self.risk_mgr.use_kelly = use_kelly + self.risk_mgr.kelly_mult = self.kelly_multiplier + + # ML 신호 필터링 + self.use_ml_signal = get_env_bool("USE_ML_SIGNAL", False) + self.ml_min_probability = get_env_float("ML_MIN_PROBABILITY", 0.57) + + # 자산 기준 (리포트용) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", 0) + + def _get_effective_slot_cap(self) -> float: + """ + 슬롯당 실투입 상한 금액 계산. + - env: SLOT_BASE_AMOUNT_CAP (직접 지정) + - env: MAX_LOSS_PER_TRADE_KRW + STOP_LOSS_PCT 로부터 역산 + (MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT|) + 둘 다 설정되어 있으면 더 작은 값 사용. + 설정이 없으면 0 리턴(제한 없음). + """ + slot_cap_env = get_env_float("SLOT_BASE_AMOUNT_CAP", 0.0) + max_loss_krw = get_env_float("MAX_LOSS_PER_TRADE_KRW", 0.0) + stop_abs = abs(self.stop_loss_pct) if self.stop_loss_pct != 0 else 0.0 + derived_cap = 0.0 + if max_loss_krw > 0 and stop_abs > 0: + try: + derived_cap = max_loss_krw / stop_abs + except Exception: + derived_cap = 0.0 + candidates = [c for c in (slot_cap_env, derived_cap) if c and c > 0] + if not candidates: + return 0.0 + return float(min(candidates)) + + def _seconds_until_next_5min(self): + """다음 5분 정각까지 남은 초 계산""" + now = dt.now() + next_min = ((now.minute // 5) + 1) * 5 + if next_min >= 60: + next_time = now.replace(hour=now.hour + 1, minute=0, second=0, microsecond=0) + else: + next_time = now.replace(minute=next_min, second=0, microsecond=0) + return (next_time - now).total_seconds() + + def update_universe(self): + """ + 유니버스 업데이트 (5분마다 호출) - Ver2: 스캔/API 미사용. + 후보는 target_candidates 테이블에서만 읽음 (get_target_candidates). + DB는 키움 등 외부에서 채우고, 여기서는 API 호출·DB 쓰기 없음. + """ + logger.info(f"🔄 [유니버스] DB 전용 모드 | 후보는 target_candidates 테이블에서만 조회 (API 미호출)") + # 가져오기는 매매 루프에서 self.db.get_target_candidates() 로 유지 + + async def _universe_scan_scheduler(self): + """5분마다 정각에 유니버스 스캔 실행 (비동기 백그라운드)""" + loop = asyncio.get_event_loop() + while True: + try: + if self.is_first_run: + wait_sec = 0 # 첫 실행은 즉시 + else: + wait_sec = max(0, self._seconds_until_next_5min()) + if wait_sec > 0: + await asyncio.sleep(wait_sec) + now = dt.now() + logger.info(f"🔄 [스캔 주기] 정각 스캔 시작 | 시각:{now.hour:02d}:{now.minute:02d}:{now.second:02d}") + # 동기 함수를 executor에서 실행 (메인 루프 블로킹 방지) + await loop.run_in_executor(None, self.update_universe) + self.is_first_run = False + await asyncio.sleep(5) # 스캔 직후 5초 대기 (과부하 방지) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [스캔 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _report_scheduler(self): + """리포트 전송 스케줄러 (비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 13:00 - 오전 리포트 + AI 리포트 + if now.hour == 13 and now.minute == 0 and not self.morning_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_morning_report) + await loop.run_in_executor(None, self.send_ai_report) + + # 15:15 - 장마감 전 리포트 + elif now.hour == 15 and now.minute == 15 and not self.closing_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_closing_report) + + # 15:35 - 최종 리포트 + elif now.hour == 15 and now.minute == 35 and not self.final_report_sent: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self.send_final_report) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [리포트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + async def _asset_update_scheduler(self): + """자산 정보 업데이트 스케줄러 (30분마다, 비동기 백그라운드)""" + while True: + try: + await asyncio.sleep(60) # 1분마다 체크 + now = dt.now() + + # 30분마다 자산 업데이트 + if now.minute % 30 == 0: + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._update_assets) + await asyncio.sleep(60) # 업데이트 후 1분 대기 (중복 방지) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [자산 업데이트 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + def _sync_holdings_from_balance(self, balance): + """ + 계좌 잔고 API 응답(output1)으로 self.holdings 동기화. + 폰·타 앱으로 매수한 종목이 계좌에 있으면 holdings에 반영하고, 매도한 종목은 제거. + """ + try: + output1 = balance.get("output1") or [] + if isinstance(output1, dict): + output1 = [output1] + if not isinstance(output1, list): + return + api_codes = set() + for item in output1: + code = (item.get("pdno") or item.get("PDNO") or "").strip() + if not code: + continue + # 보유수량 (한투: hldg_qty 등) + qty_val = item.get("hldg_qty") or item.get("HLDG_QTY") or item.get("ord_qty") or item.get("ORD_QTY") or 0 + qty = int(float(str(qty_val).replace(",", ""))) if qty_val is not None else 0 + if qty <= 0: + continue + api_codes.add(code) + # 매입평균가 (한투 pchs_avg_pric). 없거나 0이면 평가금액-평가손익으로 역산 + avg_pr_raw = item.get("pchs_avg_pric") or item.get("PCHS_AVG_PRIC") + api_buy_price = 0.0 + if avg_pr_raw is not None: + avg_pr_str = str(avg_pr_raw).replace(",", "").strip() + if avg_pr_str not in ("", "0", "0.0"): + try: + api_buy_price = abs(float(avg_pr_str)) + except Exception: + api_buy_price = 0.0 + if api_buy_price <= 0: + try: + evlu_amt = float(item.get("evlu_amt") or item.get("EVLU_AMT") or 0) + evlu_pfls = float(item.get("evlu_pfls_amt") or item.get("EVLU_PFLS_AMT") or 0) + cost = evlu_amt - evlu_pfls + api_buy_price = cost / qty if qty and cost > 0 else 0.0 + except Exception: + api_buy_price = 0.0 + if api_buy_price <= 0: + api_buy_price = 0.0 + # 매수일시 (API에 있으면 그때로, 없으면 현재) + buy_time_str = item.get("fstc_pchs_dt") or item.get("FSTC_PCHS_DT") or item.get("pchs_dt") or item.get("PCHS_DT") or item.get("ord_dt") or "" + buy_time_str = (buy_time_str or "").strip().replace("-", "").replace(" ", "").replace(":", "") + if len(buy_time_str) >= 14: + try: + buy_time = dt.strptime(buy_time_str[:14], "%Y%m%d%H%M%S").strftime("%Y-%m-%d %H:%M:%S") + except Exception: + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + elif len(buy_time_str) >= 8: + try: + buy_time = dt.strptime(buy_time_str[:8], "%Y%m%d").strftime("%Y-%m-%d %H:%M:%S") + except Exception: + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + else: + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + name = (item.get("prdt_name") or item.get("PRDT_NAME") or item.get("prdt_name_eng") or code or "").strip() + if not name: + name = code + existing = self.holdings.get(code) + # ⇒ buy_price 결정: API > 기존 holdings > DB 순서로 폴백 + buy_price = api_buy_price + existing_buy_price = 0.0 + if existing: + try: + existing_buy_price = float(existing.get("buy_price", 0) or 0) + except Exception: + existing_buy_price = 0.0 + if buy_price <= 0 and existing_buy_price > 0: + buy_price = existing_buy_price + if buy_price <= 0: + db_trade = None + try: + if hasattr(self.db, "get_active_trade"): + db_trade = self.db.get_active_trade(code) + except Exception as e: + logger.debug(f"잔고동기화 DB 평단 조회 실패({code}): {e}") + if db_trade: + try: + db_buy_price = float( + db_trade.get("avg_buy_price") + or db_trade.get("buy_price") + or 0 + ) + except Exception: + db_buy_price = 0.0 + if db_buy_price > 0: + buy_price = db_buy_price + if existing: + if buy_price <= 0: + # 매입가를 신뢰할 수 없으면 기존 매입가를 보존하고 수량/이름만 갱신 + logger.warning( + f"⚠️ [잔고동기화] {name} ({code}) 매입가 복원 실패 → 기존 매입가 유지" + ) + existing["qty"] = qty + if name: + existing["name"] = name + continue + # 수량/매입가 API·DB 기준으로 갱신 (폰에서 추가 매수/일부 매도 반영) + existing["qty"] = qty + existing["buy_price"] = buy_price + if name: + existing["name"] = name + continue + # API에만 있는 종목(폰 등 외부 매수) → holdings에 추가, 기본 손절/목표가 적용 + if buy_price <= 0: + logger.warning( + f"⚠️ [잔고동기화] {name} ({code}) 매입가 0/없음 → holdings 추가 스킵" + ) + continue + stop_price = buy_price * (1 + self.stop_loss_pct) if buy_price > 0 else 0 + target_price = buy_price * (1 + self.take_profit_pct) if buy_price > 0 else 0 + self.holdings[code] = { + "buy_price": buy_price, + "qty": qty, + "buy_time": buy_time, + "name": name, + "max_price": buy_price, + "atr_entry": buy_price * 0.01 if buy_price > 0 else 0, + "stop_price": stop_price, + "target_price": target_price, + } + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SHORT_ANT_SHAKING", + "avg_buy_price": buy_price, + "current_price": buy_price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": buy_time, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": self.holdings[code]["atr_entry"], + }) + logger.info(f"📲 [잔고동기화] 외부 매수 반영: {name} ({code}) {qty}주 @ {buy_price:,.0f}원") + # API에 없는 종목 제거 (다른 경로에서 매도된 경우). output1이 리스트일 때만 적용 + if isinstance(output1, list): + for code in list(self.holdings.keys()): + if code not in api_codes: + name = self.holdings[code].get("name", code) + del self.holdings[code] + try: + self.db.close_trade(code=code, sell_price=0, sell_reason="잔고동기화(외부매도)", strategy="SHORT_ANT_SHAKING") + except Exception as e: + logger.debug(f"잔고동기화 close_trade 스킵 {code}: {e}") + logger.info(f"📲 [잔고동기화] 보유 제거: {name} ({code}) - 계좌에 없음") + except Exception as e: + logger.warning(f"잔고 동기화 예외: {e}") + + def _update_assets(self, balance=None): + """자산 정보 업데이트 (잔고 동기화 포함). balance 생략 시 API 호출.""" + try: + if balance is None: + balance = self.client.get_account_balance() + if balance is None: + logger.warning( + "💵 [예수금] get_account_balance가 None 반환 → 예수금 갱신 스킵 " + "(토큰·계좌·TR ID 확인. 모의=VTTC8434R, 실전=TTTC8434R)" + ) + return + # 폰/타 앱 매매 반영: 잔고 API 기준으로 holdings 동기화 + self._sync_holdings_from_balance(balance) + # 한투 API: output1=주식 잔고(종목별), output2=예수금 관련(dnca_tot_amt 등) - 블로그·문서 기준 + def _parse_amt(v): + if v is None or str(v).strip() == "": + return None + return float(str(v).replace(",", "").strip()) + def _cash_block(obj): + if not obj: + return {} + if isinstance(obj, list) and obj: + return obj[0] + if isinstance(obj, dict): + return obj + return {} + out2 = _cash_block(balance.get("output2")) + if isinstance(balance.get("output1"), dict): + out1 = balance.get("output1", {}) + else: + out1 = {} + ord_psbl_val = _parse_amt(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash")) + dnca_tot_val = _parse_amt(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0 + # 거래가능 = D+2 예수금 (prvs_rcdl_excc_amt). 없으면 예수금총액 fallback + prvs_rcdl = _parse_amt(out2.get("prvs_rcdl_excc_amt")) + if prvs_rcdl is not None: + self.d2_excc_amt = prvs_rcdl + else: + self.d2_excc_amt = 0 + if prvs_rcdl is not None and prvs_rcdl > 0: + self.current_cash = prvs_rcdl + logger.info(f"💵 [예수금] 거래가능=D+2예수금={self.current_cash:,.0f}원") + else: + self.current_cash = dnca_tot_val + logger.info(f"💵 [예수금] 거래가능=예수금총액={self.current_cash:,.0f}원 (D+2 없음)") + # 보유 종목 평가액 계산 + holdings_value = 0 + for code, holding in self.holdings.items(): + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + holdings_value += current_price * holding["qty"] + self.current_total_asset = self.current_cash + holdings_value + if self.start_day_asset == 0: + self.start_day_asset = self.current_total_asset + except Exception as e: + logger.error(f"자산 정보 업데이트 실패: {e}") + + def _update_account_light(self, profit_val=0): + """ + 경량 계좌 갱신 (매수/매도 직후 즉시 호출!) + - API 부하를 줄이기 위해 예수금 + 보유 종목 평가액만 빠르게 계산 + - 총자산 = 예수금 + 보유 종목 평가액 (+ 손익 반영) + """ + try: + balance = self.client.get_account_balance() + if balance is None: + logger.warning("💵 [예수금-경량] get_account_balance None → 예수금 갱신 스킵") + return False + def _parse_amt_light(v): + if v is None or str(v).strip() == "": + return None + return float(str(v).replace(",", "").strip()) + def _cash_block_light(obj): + if not obj: + return {} + if isinstance(obj, list) and obj: + return obj[0] + if isinstance(obj, dict): + return obj + return {} + out2 = _cash_block_light(balance.get("output2")) + if isinstance(balance.get("output1"), dict): + out1 = balance.get("output1", {}) + else: + out1 = {} + ord_psbl_val = _parse_amt_light(out2.get("ord_psbl_cash") or out1.get("ord_psbl_cash")) + dnca_tot_val = _parse_amt_light(out2.get("dnca_tot_amt") or out1.get("dnca_tot_amt")) or 0 + prvs_rcdl = _parse_amt_light(out2.get("prvs_rcdl_excc_amt")) + if prvs_rcdl is not None: + self.d2_excc_amt = prvs_rcdl + if prvs_rcdl is not None and prvs_rcdl > 0: + new_cash = prvs_rcdl + else: + new_cash = dnca_tot_val + logger.info( + f"💵 [예수금-경량] 거래가능(D+2)={new_cash:,.0f}원 (이전={self.current_cash:,.0f})" + ) + if new_cash > 0 or self.current_cash == 0: + self.current_cash = new_cash + # 보유 종목 평가액: output1=주식 잔고(종목별), output2=예수금 요약 (블로그 기준) + output1_list = balance.get("output1", []) + if isinstance(output1_list, dict): + output1_list = [output1_list] + holdings_value = 0 + for code, holding in self.holdings.items(): + for item in output1_list: + if (item.get("pdno") or "").strip() == code: + evlu_amt = float(item.get("evlu_amt", 0)) + holdings_value += evlu_amt + break + else: + price_data = self.client.inquire_price(code) + if price_data: + current_price = abs(float(price_data.get("stck_prpr", 0))) + holdings_value += current_price * holding["qty"] + self.current_total_asset = self.current_cash + holdings_value + if profit_val != 0: + self.current_total_asset += profit_val + logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원 | 총자산: {self.current_total_asset:,.0f}원") + return True + except Exception as e: + logger.error(f"❌ 경량 갱신 실패: {e}") + return False + + def _update_cash_only(self): + """예수금만 빠르게 업데이트 (하위 호환성용, _update_account_light 사용 권장)""" + return self._update_account_light(profit_val=0) + + def send_mm(self, msg): + """Mattermost 알림 전송. 성공 시 True, 실패 시 False.""" + try: + return self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + return False + + def check_market_status(self): + """장 운영 시간 체크""" + # FORCE_MARKET_OPEN 플래그 확인 (테스트용) + force_open = get_env_bool("FORCE_MARKET_OPEN", False) + if force_open: + logger.debug("🔓 FORCE_MARKET_OPEN=true - 장 상태 무시하고 계속 진행") + return True + + # 정상 장 운영 시간 체크 + now = dt.now() + if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)): + return False + if now.weekday() >= 5: # 주말 + return False + return True + + def send_morning_report(self): + """오전 장 뜸할 때 리포트 (13:00)""" + if self.morning_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + msg = f"""📊 **[오전 장 현황 - 13:00]** +- 당일 시작: {self.start_day_asset:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 보유 종목: {len(self.holdings)}개""" + + self.send_mm(msg) + self.morning_report_sent = True + logger.info("📊 오전 리포트 전송 완료") + + def send_closing_report(self): + """장마감 전 리포트 (15:15)""" + if self.closing_report_sent: + return + + self._update_assets() + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + msg = f"""📈 **[장마감 전 현황 - 15:15]** +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 현재 자산: {self.current_total_asset:,.0f}원 +- 보유 종목: {len(self.holdings)}개 +- 예수금(주문가능): {self.current_cash:,.0f}원 | D+2예수금: {self.d2_excc_amt:,.0f}원""" + + self.send_mm(msg) + self.closing_report_sent = True + logger.info("📈 장마감 전 리포트 전송 완료") + + def send_final_report(self): + """장마감 후 최종 리포트 (15:35)""" + if self.final_report_sent: + return + + self._update_assets() + + # 당일 손익 + day_pnl = self.current_total_asset - self.start_day_asset + if self.start_day_asset > 0: + day_pnl_pct = day_pnl / self.start_day_asset * 100 + else: + day_pnl_pct = 0 + + # 누적 손익 + cumulative_pnl = self.current_total_asset - self.total_deposit + if self.total_deposit > 0: + cumulative_pnl_pct = cumulative_pnl / self.total_deposit * 100 + else: + cumulative_pnl_pct = 0 + + # 오늘 거래 내역 + today_trades = self.db.get_trades_by_date(self.today_date) + + msg = f"""🏁 **[장마감 최종 보고 - 15:35]** +━━━━━━━━━━━━━━━━━━━━ +📅 **당일 손익** +- 시작: {self.start_day_asset:,.0f}원 +- 종료: {self.current_total_asset:,.0f}원 +- 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) + +💰 **누적 손익 (총 입금액 대비)** +- 총 입금: {self.total_deposit:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 누적 손익: {cumulative_pnl:+,.0f}원 ({cumulative_pnl_pct:+.2f}%) + +📊 **거래 현황** +- 오늘 매매: {len(today_trades)}건 +- 보유 종목: {len(self.holdings)}개 +- 예수금(주문가능): {self.current_cash:,.0f}원 | D+2예수금: {self.d2_excc_amt:,.0f}원 +━━━━━━━━━━━━━━━━━━━━""" + + self.send_mm(msg) + self.final_report_sent = True + logger.info("🏁 장마감 최종 리포트 전송 완료") + + def _get_env_numeric_snapshot(self): + """DB 최신 env에서 계좌/키/토큰/URL 제외한 수치·설정만 반환 (키=값 줄 단위).""" + 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", + } + latest = self.db.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 = (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_journalctl_recent(self, lines=200, unit=None): + """journalctl 최근 N줄. unit 있으면 -u unit 적용 (예: kis_short_ver2.service).""" + 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: + logger.warning(f"⚠️ journalctl 조회 실패: {e}") + return "" + + def send_ai_report(self): + """AI 분석 리포트 (13:00) - DB 수치만 보여주고, 거래 내역으로 승률 분석·추천(설정수치=값 형식).""" + if self.ai_report_sent or not gemini_client: + return + + try: + # DB에서 수치 설정만 (계좌/키/토큰 제외) + env_lines = self._get_env_numeric_snapshot() + + # journalctl 최근 로그 (라인 수는 env/DB에서 로드) + log_lines = get_env_int("AI_JOURNAL_LINES", 500) + try: + log_lines_int = int(log_lines) + except Exception: + log_lines_int = 500 + if log_lines_int <= 0: + log_lines_int = 500 + journal_unit = os.environ.get("JOURNALCTL_UNIT", "kis_short_ver2.service").strip() or None + journal_log = self._get_journalctl_recent(lines=log_lines_int, unit=journal_unit) + if not journal_log: + journal_log = "(journalctl 로그 없음)" + + # 최근 거래 내역 조회 + recent_trades = [] + try: + conn = self.db.conn + cursor = 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 10 + """) + for row in cursor.fetchall(): + recent_trades.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: + logger.error(f"거래 내역 조회 실패: {e}") + return + + db_candidates = self.db.get_target_candidates() + candidate_count = len(db_candidates) + + # 거래 내역 없을 때: 수치만 보여주고 유니버스·추천 + if not recent_trades: + summary = f"""📊 **현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: 없음""" + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +**현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: 없음 + +**현재 DB 설정 수치 (일부만 표시됨, 계좌/키 등 제외)** +``` +{env_lines} +``` + +**봇 최근 로그 (journalctl 최근 {log_lines_int}줄) – 탈락 사유·API 과부하·매수 시도 등 참고** +``` +{journal_log[:15000]} +``` + +**당신의 임무** +1. 위 설정과 후보 수({candidate_count}개)를 보고 문제점 분석 (필터가 너무 까다로운지 등). +2. **추천**: 반드시 아래 형식으로만 한 줄에 하나씩 작성. 이유·주석 붙이지 말 것. 그대로 DB에 복붙해 적용할 수 있어야 함. + - 예: MAX_STOCKS=4 + - 예: MIN_DROP_RATE=0.025 + - 예: MIN_RECOVERY_RATIO_SHORT=0.4 +3. 예상 효과 한두 줄. + +**출력 형식 (반드시 준수)** +## 🔍 문제점 +1. [구체적 문제 1] +2. [구체적 문제 2] + +## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용 가능하게) +MAX_STOCKS=4 +MIN_DROP_RATE=0.025 +(필요한 것만, 변수명은 위 설정 목록에 있는 것만 사용) + +## 📈 예상 효과 +- [효과 1] +""" + + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) + analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") + + # 마지막 AI 추천문 저장 (!적용 시 매터모스트 원격 조종용) + _save_ai_recommendations_from_text(self.db, analysis) + + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** + +{summary} + +{analysis} + +--- +💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다. +""" + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료 (거래 내역 없음)") + return + + # 통계 계산 + total = len(recent_trades) + wins = sum(1 for t in recent_trades if t['profit_rate'] > 0) + losses = total - wins + win_rate = (wins / total * 100) if total > 0 else 0 + avg_profit = sum(t['profit_rate'] for t in recent_trades) / total + total_pnl = sum(t['realized_pnl'] for t in recent_trades) + avg_hold = sum(t['hold_minutes'] for t in recent_trades) / total + + trades_text = "" + for i, t in enumerate(recent_trades, 1): + trades_text += f""" +[거래 {i}] {t['name']} ({t['strategy']}) +- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 | 매도: {t['sell_price']:,.0f}원 +- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) | 보유: {t['hold_minutes']}분 +- 사유: {t['sell_reason']} +""" + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. + +**현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:,.0f}원 | 평균 보유: {avg_hold:.0f}분 + +**최근 거래 내역** +{trades_text} + +**현재 DB 설정 수치 (계좌/키 등 제외, 수치만)** +``` +{env_lines} +``` + +**봇 최근 로그 (journalctl 최근 {log_lines_int}줄) – 탈락 사유·API 과부하·매수 시도 등 참고** +``` +{journal_log[:15000]} +``` + +**당신의 임무** +1. **승률이 왜 떨어졌는지** 거래 내역과 설정 수치를 보고 구체적으로 3가지 진단 (손절/진입/보유 등). +2. **추천**: 반드시 "설정수치=값" 한 줄에 하나만. 이유·주석 붙이지 말 것. 그대로 DB에 복붙해 적용할 수 있어야 함. + - 예: MAX_STOCKS=4 + - 예: MIN_DROP_RATE=0.025 + - 예: MIN_RECOVERY_RATIO_SHORT=0.4 + 변수명은 위 설정 목록에 있는 것만 사용. +3. 예상 효과 한두 줄. + +**출력 형식 (반드시 준수)** +## 🔍 문제점 (승률 하락 원인) +1. [구체적 문제 1] +2. [구체적 문제 2] +3. [구체적 문제 3] + +## 💡 수치 추천 (한 줄에 하나, 그대로 DB 적용) +MAX_STOCKS=4 +MIN_DROP_RATE=0.025 +(필요한 것만) + +## 📈 예상 효과 +- [효과 1] +""" + + response = gemini_client.models.generate_content(model=GEMINI_MODEL_ID, contents=prompt) + analysis = getattr(response, "text", None) or (response.candidates[0].content.parts[0].text if response.candidates else "") + + # 마지막 AI 추천문 저장 (!적용 시 매터모스트 원격 조종용) + _save_ai_recommendations_from_text(self.db, analysis) + + summary = f"""📊 **현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {losses}패) +- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:+,.0f}원 | 평균 보유: {avg_hold:.0f}분""" + + message = f"""🤖 **[13시 AI 자동 분석 + 수치 추천]** + +{summary} + +{analysis} + +--- +💬 단타 전략 최적화. 추천 줄은 그대로 DB에 적용 가능합니다. +""" + self.send_mm(message) + self.ai_report_sent = True + logger.info("🤖 AI 리포트 전송 완료") + + except Exception as e: + logger.error(f"AI 리포트 생성 실패: {e}") + + def _fetch_scan_universe_from_api(self, max_codes=500): + """ + KIS API로 스캔 대상 종목 리스트 조회 (6소스 각 100건 → 최대 500개). + - 거래량·거래대금·회전율·등락률(상승)·등락률(하락)·거래증가율 각 100건 합산 후 중복 제거. + - 리스트는 DB에 저장하지 않음. 스캔 끝난 뒤 후보만 DB에 한 번에 인서트. + Returns: + list[dict]: [{"code": "006자리", "name": "종목명"}, ...] (중복 제거, 최대 max_codes개) + """ + def _code_from_item(item): + code = (item.get("stk_cd") or item.get("mksc_shrn_iscd") or item.get("code") or "").strip() + return code if code and len(code) == 6 else None + + def _name_from_item(item): + return ( + (item.get("stk_nm") or item.get("prst_name") or item.get("hts_kor_isnm") or "").strip() + or "" + ) + + scan_list = [] + seen = set() + + # 1) 거래량순위 100개 (키움은 거래대금+회전율만 사용, KIS는 거래량+거래대금+회전율 3가지로 풀 확대) + try: + time.sleep(random.uniform(0.5, 1.0)) + vol_list = self.client.get_volume_rank(market="J", limit=100) + for item in (vol_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래량순위 API → {len(vol_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래량순위 조회 실패: {e}") + + # 2) 거래대금순위 100개 (키움과 동일 소스) + try: + time.sleep(random.uniform(0.5, 1.0)) + val_list = self.client.get_trading_value_rank(market="J", limit=100) + for item in (val_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래대금순위 API → {len(val_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래대금순위 조회 실패: {e}") + + # 3) 회전율순위 100개 (키움 개미털기 2번째 소스와 동일) + try: + time.sleep(random.uniform(0.5, 1.0)) + turn_list = self.client.get_turnover_rank(market="J", limit=100) + for item in (turn_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 회전율순위 API → {len(turn_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 회전율순위 조회 실패: {e}") + + # 4) 등락률순위(상승) 100개 (기존 후보군 풀 확대) + try: + time.sleep(random.uniform(0.5, 1.0)) + chg_list = self.client.get_price_change_rank(market="J", sort_type="1", limit=100) + for item in (chg_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 등락률순위(상승) API → {len(chg_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 등락률순위(상승) 조회 실패: {e}") + + # 4-2) 등락률순위(하락) 100개 — 낙폭 큰 종목 직접 조회 → N자 망치봉 스캔 효율·Pass-낙폭 포착 + try: + time.sleep(random.uniform(0.5, 1.0)) + decline_list = self.client.get_price_decline_rank(market="J", limit=100) + for item in (decline_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 등락률순위(하락) API → {len(decline_list)}건 수신, 누적 {len(scan_list)}종목") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 등락률순위(하락) 조회 실패: {e}") + + # 5) 거래증가율순위 100개 (거래량/대금/회전율과 다른 풀 → 후보 다양화) + try: + time.sleep(random.uniform(0.5, 1.0)) + growth_list = self.client.get_volume_growth_rank(market="J", limit=100) + for item in (growth_list or []): + c = _code_from_item(item) + if c and c not in seen: + seen.add(c) + scan_list.append({"code": c, "name": _name_from_item(item) or ""}) + logger.info(f" 📡 [스캔유니버스] 거래증가율순위 API → {len(growth_list)}건 수신, 누적 {len(scan_list)}종목 (6소스 합산 → 개미털기 필터)") + except Exception as e: + logger.warning(f" ⚠️ [스캔유니버스] 거래증가율순위 조회 실패: {e}") + + if not scan_list: + logger.warning(" ⚠️ [스캔유니버스] API에서 0건 수신 → 스캔 불가 (권한/계정/시간 확인)") + return [] + + scan_list = scan_list[:max_codes] + # 종목명 비어 있으면 시세 배치로 채우기 (KIS volume-rank는 종목명 미제공 시 많음) + need_name_codes = [x["code"] for x in scan_list[:20] if not (x.get("name") or "").strip()] + if need_name_codes: + try: + time.sleep(random.uniform(0.2, 0.4)) + batch = self.client.inquire_prices_batch(need_name_codes[:20]) + name_map = {} + _market_names = {"KOSPI", "KOSDAQ", "ETF", "KOSPI200", "KSQ150"} + for code, out in (batch or {}).items(): + n = (out.get("stck_kor_isnm") or out.get("rprs_mrkt_kor_name") or "").strip() + if n and n not in _market_names: + name_map[code] = n + for x in scan_list: + if not (x.get("name") or "").strip() and x["code"] in name_map: + x["name"] = name_map[x["code"]] + except Exception as e: + logger.debug(f" 스캔 종목명 배치 조회 스킵: {e}") + for x in scan_list: + if not (x.get("name") or "").strip(): + x["name"] = x["code"] + + logger.info( + f" 📋 스캔 대상: {len(scan_list)}개 종목 (거래량·거래대금·회전율·등락률상승·등락률하락·거래증가율 각 100건 합산 → 개미털기 필터)" + ) + if scan_list: + part = ", ".join(f"{x['code']} {x.get('name') or x['code']}" for x in scan_list[:15]) + logger.info(f" 📋 스캔 대상(일부): {part}{' ...' if len(scan_list) > 15 else ''}") + return scan_list + + def scan_ant_shaking_candidates(self, max_candidates=20): + """ + 개미털기(눌림목) 후보 종목 스캔 - kiwoom_trader_dual 방식: 유니버스 리스트만 빠르게 채움. + - 스캔에서는 종목별 API 호출·낙폭/회복 필터·탈락 로그 없음. + - 낙폭/회복/3분봉/RSI/피뢰침/ML 전부 메인 루프의 check_buy_signal_tail_catch에서만 체크. + """ + logger.info("🐜 [개미털기] 스캔 시작 (유니버스 리스트만 등록 → 매수 시 한곳에서 전 조건 체크)") + top_n_light = get_env_int("CANDIDATE_LIST_TOP_N_LIGHT", 20) + max_codes = get_env_int("SCAN_UNIVERSE_MAX_CODES", 150) + + scan_list = self._fetch_scan_universe_from_api(max_codes=max_codes) + if not scan_list: + logger.warning(" ⚠️ [개미털기] 스캔 대상 0개 → API에서 리스트를 받지 못함") + return [] + + n_save = min(top_n_light, len(scan_list)) + result = [] + for i, x in enumerate(scan_list[:n_save]): + name = (x.get("name") or x["code"]).strip() or x["code"] + # DB 저장은 update_universe()에서 강도 계산·정렬 후 강도순 상위 N명만 함 (여기서는 리스트만 반환) + result.append({ + "code": x["code"], + "name": name, + "price": 0, + "score": 0, + "drop_rate": 0, + "recovery": 0, + }) + + part = ", ".join(f"{x.get('name') or x['code']}({x['code']})" for x in scan_list[:5]) + logger.info(f" ✅ [개미털기] 후보 {n_save}명 수집 (강도·정렬·DB 반영은 유니버스 업데이트에서 일괄 처리) | 일부: {part}{' ...' if n_save > 5 else ''}") + return result + + def calculate_atr(self, df, period=14): + """ + ATR (Average True Range) 계산 - 변동성 지표 + - TR(True Range) = max(고가-저가, |고가-전일종가|, |저가-전일종가|) + - ATR = TR의 14일 이동평균 + """ + try: + if df is None or len(df) < period: + return 0 + + df = df.copy() + # True Range 계산 + high_low = df['high'] - df['low'] + high_close = (df['high'] - df['close'].shift()).abs() + low_close = (df['low'] - df['close'].shift()).abs() + df['tr'] = pd.concat([high_low, high_close, low_close], axis=1).max(axis=1) + + # ATR = TR의 14일 이동평균 + atr = df['tr'].rolling(window=period).mean().iloc[-1] + return float(atr) if not pd.isna(atr) and atr > 0 else 0 + except Exception as e: + logger.debug(f"ATR 계산 실패: {e}") + return 0 + + def _df_to_engine_candles(self, df) -> List[Dict]: + """DataFrame → tail_engine 형식 (candle_time YYYYMMDDHHMI, open, high, low, close, volume).""" + candles = [] + for idx in range(len(df)): + row = df.iloc[idx] + t = row.get("time") or row.get("candle_time") or "" + if hasattr(t, "strftime"): + t = t.strftime("%Y%m%d%H%M") + if isinstance(t, str) and len(t) >= 12: + t = t.replace("-", "").replace(" ", "").replace(":", "")[:12] + candles.append({ + "candle_time": t or f"{dt.now().strftime('%Y%m%d')}0930", + "open": float(row.get("open", 0)), + "high": float(row.get("high", 0)), + "low": float(row.get("low", 0)), + "close": float(row.get("close", 0)), + "volume": float(row.get("volume", 0)), + }) + return candles + + def _force_buy_tail(self, code: str, name: str): + """FORCE_BUY_TEST 시 현재가만 시그널.""" + try: + price_data = self.client.inquire_price(code) + current_price = abs(float(str(price_data.get("stck_prpr", 0)).replace(",", ""))) if price_data else 0 + if current_price <= 0: + return None + return {"code": code, "name": name, "price": current_price, "score": 5.0, "entry_features": {}} + except Exception: + return None + + def check_buy_signal_tail_catch(self, code: str, name: str): + """tail_engine.check_buy_signal_live 사용 (확정봉만). V2 전용 필터(시장/테마/MA20/ML 등)만 추가.""" + try: + if get_env_bool("FORCE_BUY_TEST", False): + return self._force_buy_tail(code, name) + + if get_env_bool("USE_MARKET_REGIME_FILTER", False): + min_rsi = get_env_float("MARKET_REGIME_MIN_RSI", 48.0) + regime = self.db.get_market_regime(tf=60) + if not regime.get("is_bull") or regime.get("avg_rsi", 50) < min_rsi: + logger.debug( + "[시장필터] %s %s: 하락장 차단 (ETF RSI=%.1f < %.1f)", + code, name, regime.get("avg_rsi", 0), min_rsi, + ) + return None + if get_env_bool("USE_THEME_HEAT_FILTER", False): + heat_max = get_env_float("THEME_HEAT_RSI_MAX", 72.0) + meta = self.db.get_stock_meta(code) + if meta and meta.get("theme"): + momentum = self.db.get_theme_momentum(meta["theme"], tf=60) + if momentum.get("count", 0) >= 3 and momentum.get("avg_rsi3", 0) > heat_max: + logger.debug( + "[테마필터] %s %s: 테마(%s) 과열 차단 (RSI=%.1f > %.1f)", + code, name, meta["theme"], + momentum["avg_rsi3"], heat_max, + ) + return None + + min_candle_len = get_env_int("MIN_CANDLE_LEN_TAIL", 14) + min_price_tail = get_env_float("MIN_PRICE_TAIL", 1000.0) + candles = None + df = None + # 확정봉만 사용 (백테스트와 동일: 신호봉 확정 후 다음 봉 시가 진입) + if self.candle_agg: + raw = self.candle_agg.get_candles(code, 3, 50) # 이미 확정봉만 반환 + if raw and len(raw) >= 10: + candles = [{"candle_time": c.get("candle_time", ""), "open": float(c.get("open", 0)), "high": float(c.get("high", 0)), "low": float(c.get("low", 0)), "close": float(c.get("close", 0)), "volume": float(c.get("volume", 0))} for c in raw] + if not candles and self.db: + ws = self.db.get_ws_candles(code, 3, limit=50, confirmed_only=True) + if ws and len(ws) >= 10: + candles = [{"candle_time": str(c.get("candle_time", "")), "open": float(c.get("open", 0)), "high": float(c.get("high", 0)), "low": float(c.get("low", 0)), "close": float(c.get("close", 0)), "volume": float(c.get("volume", 0))} for c in ws] + if not candles: + df = self._get_candles_df(code, tf=3, n=20) + if df is None or df.empty: + logger.debug("📡 [%s] WS봉 없음 → REST 3분봉 fallback", code) + df = self.client.get_minute_chart(code, period="3", limit=20) + if df is None or df.empty or len(df) < min_candle_len: + logger.info(f"{LOG_YELLOW}🔍 [탈락-3분봉] {name} {code}: 봉수 부족 (len={len(df) if df is not None and not df.empty else 0}, 기준 {min_candle_len}){LOG_RESET}") + return None + if "close" not in df.columns or "high" not in df.columns or "low" not in df.columns or "open" not in df.columns: + logger.info(f"{LOG_YELLOW}🔍 [탈락-3분봉] {name} {code}: OHLC 컬럼 없음{LOG_RESET}") + return None + # 진행봉(미확정) 제외: 마지막 행 제거 후 사용 → 확정봉만으로 신호 판단 + if len(df) > 1: + df = df.iloc[:-1].copy() + if len(df) < min_candle_len: + logger.info(f"{LOG_YELLOW}🔍 [탈락-3분봉] {name} {code}: 봉수 부족 (len={len(df)}, 기준 {min_candle_len}){LOG_RESET}") + return None + candles = self._df_to_engine_candles(df) + if len(candles) < 10: + return None + + current_price = float(candles[-1]["close"]) + if current_price <= 0 or current_price < min_price_tail: + logger.info(f"{LOG_YELLOW}🔍 [탈락-가격] {name} {code}: 시가/현재가 부적절 (현재 {current_price:,.0f}원, 최소 {min_price_tail:,.0f}){LOG_RESET}") + return None + + today_yyyymmdd = dt.now().strftime("%Y%m%d") + last_exit_dt = None + if code in self.recently_sold: + try: + last_exit_dt = dt.fromtimestamp(self.recently_sold[code]) + if last_exit_dt.strftime("%Y%m%d") != today_yyyymmdd: + last_exit_dt = None + except Exception: + pass + try: + today_trades = self.db.get_trades_by_date(self.today_date) + daily_cnt = len([t for t in today_trades if t.get("code") == code and str(t.get("strategy", "")).startswith("SHORT_")]) + except Exception: + daily_cnt = 0 + state = {"last_exit_dt": last_exit_dt, "daily_cnt": daily_cnt} + + _d = te.get_tail_defaults_from_db(self.db) + params = { + **_d, + "min_drop_rate": self.min_drop_rate, + "min_recovery_ratio": self.min_recovery_ratio, + "tail_ratio_min": get_env_float("TAIL_RATIO_MIN", 1.5), + "tail_pct_min": get_env_float("TAIL_PCT_MIN", 0.003), + "sl_pct": abs(self.stop_loss_pct) if self.stop_loss_pct < 0 else 0.03, + "tp_pct": self.take_profit_pct, + } + + reject_reason, reject_msg, sig = te.check_buy_signal_live(candles, params, state) + if reject_reason: + logger.info(f"{LOG_YELLOW}🔍 [{reject_reason}] {name} {code}: {reject_msg}{LOG_RESET}") + return None + if sig is None: + return None + # 엔진 통과 → 통과 로그 (백테스트와 동일 로직) + logger.info(f"{LOG_GREEN}🔍 [통과-낙폭·회복] {name} {code} → 꼬리/피뢰침/RSI 체크{LOG_RESET}") + + # V2 전용: 피뢰침 급등주, MA20, ML (엔진에는 없음) — df 없으면 캔들 리스트에서 계산 + if df is not None and not df.empty: + day_open = float(df["open"].iloc[0]) + day_high = float(df["high"].max()) + else: + day_open = float(candles[0]["open"]) + day_high = max(float(c["high"]) for c in candles) + try: + if df is not None and not df.empty: + lows = df["low"] + valid = lows[lows > 0] + day_low = float(valid.min()) if not valid.empty else float(df["low"].min()) + else: + day_low = min(float(c["low"]) for c in candles if float(c.get("low", 0)) > 0) if candles else 0 + if day_low <= 0: + day_low = float(candles[0]["low"]) if candles else 0 + except Exception: + day_low = float(candles[0]["low"]) if candles else 0 + if self.ws_cache and self.ws_cache.is_active: + w = self.ws_cache.get_price(code) + if w: + day_open = abs(float(str(w.get("stck_oprc", day_open)).replace(",", ""))) or day_open + day_high = abs(float(str(w.get("stck_hgpr", day_high)).replace(",", ""))) or day_high + day_low = abs(float(str(w.get("stck_lwpr", day_low)).replace(",", ""))) or day_low + current_price = abs(float(str(w.get("stck_prpr", current_price)).replace(",", ""))) or current_price + high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + if current_price >= day_high * high_chase_threshold: + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침 고점추격] {name} {code}: 현재가 {current_price:,.0f} ≥ 고점대비 {high_chase_threshold*100:.0f}%{LOG_RESET}") + return None + if day_low > 0: + range_change_pct = (day_high - day_low) / day_low * 100 + else: + range_change_pct = 0 + max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", 20.0) + if range_change_pct > max_daily_change: + logger.info(f"{LOG_YELLOW}🔍 [탈락-피뢰침 급등주] {name} {code}: 일일 변동폭 {range_change_pct:.1f}% > {max_daily_change:.0f}%{LOG_RESET}") + return None + if df is not None and not df.empty and "MA20" in df.columns and len(df) >= 20: + ma20 = float(df["MA20"].iloc[-1]) + if current_price < ma20: + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20] {name} {code}: 현재가 {current_price:,.0f} < MA20 {ma20:,.0f}{LOG_RESET}") + return None + ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", 3.0) + if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100): + logger.info(f"{LOG_YELLOW}🔍 [탈락-MA20초과] {name} {code}: MA20 대비 {ma20_cap_pct:.0f}% 초과{LOG_RESET}") + return None + tail_ratio = sig.get("tail_ratio", 0) + recovery_pos = sig.get("recovery_pos", 0) + rsi_val = sig.get("rsi_val", 50.0) + tail_pct = sig.get("tail_pct", 0) + entry_features = { + "rsi": rsi_val, + "volume_ratio": None, + "tail_length_pct": tail_pct * 100, + "ma5_gap_pct": None, + "ma20_gap_pct": None, + "foreign_net_buy": 0, + "institution_net_buy": 0, + "market_hour": dt.now().hour, + } + if self.use_ml_signal and getattr(self, "ml_predictor", None): + try: + ml_prob = getattr(self, "ml_predictor", None).predict_win_probability(entry_features) + ml_min_prob = getattr(self, "ml_min_probability", 0.55) + if ml_prob < ml_min_prob: + logger.info(f"{LOG_YELLOW}🔍 [탈락-ML] {name} {code}: 승률 {ml_prob:.2%} < {ml_min_prob:.0%}{LOG_RESET}") + return None + except Exception: + pass + + score_base = get_env_float("TAIL_SCORE_BASE", 5.0) + score_ratio_mult = get_env_float("TAIL_SCORE_RATIO_MULT", 2.0) + score = score_base + (tail_ratio * score_ratio_mult) + logger.info( + f"{LOG_CYAN}🎯 [무릎 타점] {name} | 가격:{current_price:,.0f} | " + f"꼬리비율:{tail_ratio:.1f}배 | 회복:{recovery_pos*100:.0f}% | RSI:{rsi_val:.1f}{LOG_RESET}" + ) + return { + "code": code, + "name": name, + "price": current_price, + "score": score, + "entry_features": entry_features, + } + except Exception as e: + logger.info(f"{LOG_YELLOW}🔍 [탈락-예외] {name} {code}: {e}{LOG_RESET}") + return None + + def check_sell_signals(self): + """tail_engine.check_sell_signal_live 사용 (손절/익절/어깨컷/장마감) + 금액손실컷만 V2 유지.""" + if not self.holdings: + return [] + sell_signals = [] + min_hold_sec = get_env_float("MIN_HOLD_AFTER_BUY_SEC", 30.0) + now = dt.now() + is_eod = (now.hour == 15 and now.minute >= 25) or now.hour > 15 + _params = te.get_tail_defaults_from_db(self.db) + _params["sl_pct"] = abs(self.stop_loss_pct) if self.stop_loss_pct < 0 else 0.03 + _params["tp_pct"] = self.take_profit_pct + + for code, holding in list(self.holdings.items()): + try: + name = holding.get("name", code) + buy_price = holding["buy_price"] + buy_time_str = holding.get("buy_time", "") + qty = holding["qty"] + if buy_time_str and min_hold_sec > 0: + try: + buy_time = dt.strptime(buy_time_str, "%Y-%m-%d %H:%M:%S") + if (now - buy_time).total_seconds() < min_hold_sec: + continue + except Exception: + pass + + price_data = None + if self.ws_cache and self.ws_cache.is_active: + price_data = self.ws_cache.get_price(code) + if not price_data: + price_data = self.client.inquire_price(code) + if not price_data: + continue + current_price = abs(float(price_data.get("stck_prpr", 0))) + if current_price == 0: + continue + + max_price = holding.get("max_price", buy_price) + if current_price > max_price: + max_price = current_price + self.holdings[code]["max_price"] = max_price + profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0 + profit_val = (current_price - buy_price) * qty + + # V2 전용: 금액손실컷 + if profit_val <= -get_env_int("MAX_LOSS_PER_TRADE_KRW", 200000): + sell_signals.append({ + "code": code, "name": name, "current_price": current_price, "price": current_price, + "qty": qty, "buy_price": buy_price, "profit_pct": profit_pct, + "reason": f"금액손실컷 ({profit_val:,.0f}원)", + }) + continue + + position = { + "entry_price": buy_price, + "entry_time": holding.get("buy_time", ""), + "stop": holding.get("stop_price", buy_price * (1 + self.stop_loss_pct)), + "target": holding.get("target_price", buy_price * (1 + self.take_profit_pct)), + "max_price": max_price, + } + current_candle = {"high": max_price, "low": current_price, "close": current_price} + res = te.check_sell_signal_live(position, current_candle, _params, is_eod=is_eod) + if res: + reason, _ = res + sell_signals.append({ + "code": code, "name": name, "current_price": current_price, "price": current_price, + "qty": qty, "buy_price": buy_price, "profit_pct": profit_pct, "reason": reason, + }) + except Exception as e: + logger.error("매도 신호 체크 오류(%s): %s", code, e) + return sell_signals + + def execute_buy(self, signal): + """ + 매수 실행 + - 키움 봇과 동일하게 대형주/소형주 비율 맞추는 로직 포함 + - 대형주: 기본 금액 100% / 중형주: 85% / 소형주: 70% + - 켈리 기반 매수 금액 + 종목당 최대 15% 제한 + """ + code = signal["code"] + name = signal["name"] + price = signal["price"] + + # 이미 보유 중이면 스킵 + if code in self.holdings: + logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵") + return False + + # 최대 보유 종목 수 체크 + if len(self.holdings) >= self.max_stocks: + logger.warning(f"⚠️ 최대 보유 종목 수 도달 ({self.max_stocks}개)") + return False + + # 🔥 매수 직전 예수금 실시간 확인 + if not self._update_account_light(profit_val=0): + logger.warning(f"⚠️ [{name}] 예수금 조회 실패 -> 매수 스킵") + return False + + # ============================================================ + # [대/중/소형주 구분] - 일봉 거래대금 평균 (키움 봇과 동일) + # ============================================================ + size_class = None + try: + df = self.client.get_daily_chart(code, limit=10) + if not df.empty and "volume" in df.columns and "close" in df.columns: + trade_values = df["volume"] * df["close"] + avg_trade_value = trade_values.mean() + large_min = get_env_float("SIZE_CLASS_LARGE_MIN", 5000000000) # 50억 (대형주) + mid_min = get_env_float("SIZE_CLASS_MID_MIN", 500000000) # 5억 (중형주) + if avg_trade_value >= large_min: + size_class = "대" + elif avg_trade_value >= mid_min: + size_class = "중" + else: + size_class = "소" + logger.info( + f"📊 [{name}] 거래대금 평균 {avg_trade_value/1e8:.1f}억원 → {size_class}형주" + ) + except Exception as e: + logger.debug(f"대/중/소형 조회 스킵({code}): {e}") + + # ============================================================ + # [매수 금액] 변동성 역가중 (Volatility Inverse Weighting) + # ============================================================ + # ATR 계산용 분봉 데이터 — WS 메모리 봉 우선, 없으면 REST fallback + df_minute = None + try: + df_minute = self._get_candles_df(code, tf=3, n=20) + if df_minute is None or df_minute.empty: + df_minute = self.client.get_minute_chart(code, period="3", limit=20) + except Exception as e: + logger.debug(f"분봉 조회 실패({code}): {e}") + + # RiskManager 사용 시: 변동성 역가중으로 매수 금액 계산 + if self.risk_mgr is not None: + # 켈리 비율 (DB에서 계산, 없으면 None) + kelly_fraction = None + if self.risk_mgr.use_kelly: + try: + kelly_fraction = self.db.calculate_half_kelly() + except Exception as e: + logger.debug(f"켈리 비율 계산 스킵: {e}") + + # 변동성 역가중 매수 금액 계산 + amount = self.risk_mgr.get_position_size( + stock_name=name, + current_balance=self.current_cash, + df=df_minute, # ATR 계산용 분봉 데이터 + kelly_fraction=kelly_fraction, + size_class=size_class, # 대/중/소형 구분 + ) + + if amount <= 0: + logger.warning(f"⚠️ [{name}] RiskManager 계산 금액 0원 -> 매수 스킵") + return False + + # 수량 계산 (수수료 고려) + qty = self.risk_mgr.calculate_quantity(price, amount) + else: + # 폴백: 기존 고정 슬롯 방식 (RiskManager 미사용 시, 수치: env/DB) + slot_default = int(get_env_float("SLOT_MONEY_DEFAULT", 100000.0)) + if self.max_stocks > 0: + slot_money = int(self.current_cash * 0.9 / self.max_stocks) + else: + slot_money = slot_default + base_amount = slot_money + # 슬롯 캡(손절 기반 상한) 적용: MAX_LOSS_PER_TRADE_KRW / |STOP_LOSS_PCT| 와 SLOT_BASE_AMOUNT_CAP 중 작은 값 + effective_slot_cap = self._get_effective_slot_cap() + if effective_slot_cap > 0: + base_amount = min(base_amount, int(effective_slot_cap)) + if self.stop_loss_pct != 0: + stop_pct_abs = abs(self.stop_loss_pct) + else: + stop_pct_abs = 0.04 + if stop_pct_abs > 0: + kelly_risk_amount = self.current_cash * self.risk_pct_per_trade * self.kelly_multiplier + kelly_based_amount = int(kelly_risk_amount / stop_pct_abs) + base_amount = min(base_amount, kelly_based_amount) + small_ratio = get_env_float("SIZE_CLASS_SMALL_RATIO", 0.7) + mid_ratio = get_env_float("SIZE_CLASS_MID_RATIO", 0.85) + if size_class == "소": + amount = int(base_amount * small_ratio) + logger.info(f"💰 [{name}] 소형주 → 매수 금액 {small_ratio*100:.0f}%: {amount:,.0f}원") + elif size_class == "중": + amount = int(base_amount * mid_ratio) + logger.info(f"💰 [{name}] 중형주 → 매수 금액 {mid_ratio*100:.0f}%: {amount:,.0f}원") + else: + amount = base_amount + max_limit = int(self.current_cash * self.max_position_pct) + if amount > max_limit: + logger.info(f"📐 [{name}] 최대 포지션 제한: {amount:,.0f}원 → {max_limit:,.0f}원") + amount = max_limit + amount = max(amount, self.min_position_amount) + qty = int(amount / price) + if qty <= 0: + logger.warning(f"⚠️ [{name}] 매수 수량 0 (가격: {price:,.0f}원, 금액: {amount:,.0f}원)") + return False + + required_amount = price * qty * 1.05 + if self.current_cash < required_amount: + logger.warning( + f"⚠️ [{name}] 예수금 부족: 필요 {required_amount:,.0f}원 / " + f"보유 {self.current_cash:,.0f}원 -> 매수 스킵" + ) + return False + + # ATR 계산 (변동성 기반 손절가/목표가 설정용) + # df_minute는 위에서 이미 조회했으므로 재사용 + atr = 0 + stop_price = price * (1 + self.stop_loss_pct) + target_price = price * (1 + self.take_profit_pct) + if df_minute is not None and not df_minute.empty: + try: + atr = self.calculate_atr(df_minute) + if atr > 0: + # ATR 기반 손절가/목표가 설정 + stop_price = price - (atr * self.stop_atr_multiplier) + target_price = price + (atr * self.target_atr_multiplier) + logger.info(f"📊 [{name}] ATR 기반 손절가/목표가: ATR={atr:.0f}원, 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + except Exception as e: + logger.debug(f"ATR 계산 스킵({code}): {e}") + atr = price * 0.01 # 기본값 1% + else: + atr = price * 0.01 # 기본값 1% + + odno = self.client.buy_market_order(code, qty) + if not odno: + # 매매불가 종목이면 당일 제외 목록에 넣고 다음 후보로 넘어가기 + msg_cd = getattr(self.client, "_last_order_msg_cd", None) or "" + msg1 = getattr(self.client, "_last_order_msg1", "") or "" + if msg_cd == "40070000" or "매매불가" in msg1: + self.untradable_skip_set.add(code) + logger.warning(f"🚫 [{name}] 매매불가 종목 -> 당일 매수 제외, 다음 후보로 넘어감") + return False + # 주문 접수 후 체결 조회 API로 실제 체결가/체결수량 확인 (주문만 보고 가정하지 않음) + fill = self.client.get_execution_by_odno(odno, code=code, wait_sec=2) + if fill: + price = fill["avg_price"] + qty = fill["filled_qty"] + if qty <= 0: + logger.warning(f"⚠️ [{name}] 체결 조회 수량 0 -> 매수 반영 스킵") + return False + else: + # 체결 조회 실패 시 주문 수량/시그널가로 반영 (기존 동작, 로그로 구분) + logger.info(f"📋 [{name}] 체결 조회 미확인 -> 주문기준으로 반영 (가격={price:,.0f}, 수량={qty})") + self._update_account_light(profit_val=0) + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + self.holdings[code] = { + "buy_price": price, + "qty": qty, + "buy_time": buy_time, + "name": name, + "max_price": price, # 고점 추적 + "atr_entry": atr, # 매수 시점 ATR 저장 + "stop_price": stop_price, # 손절가 + "target_price": target_price, # 목표가 + } + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "SHORT_ANT_SHAKING", + "avg_buy_price": price, + "current_price": price, + "target_qty": qty, + "current_qty": qty, + "status": "HOLDING", + "buy_date": buy_time, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": atr, + "entry_features": signal.get("entry_features"), + }) + if fill: + logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 (API 체결 확인) | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + else: + logger.info(f"💰 [매수 체결] {name} ({code}): {price:,.0f}원 × {qty}주 (주문기준) | 손절={stop_price:,.0f}원, 목표={target_price:,.0f}원") + + # 매수 후 WebSocket 구독 등록 → 이후 check_sell_signals에서 REST 없이 실시간 수신 + if self.ws_cache and self.ws_cache.is_active: + self.ws_cache.subscribe(code) + # 신규 보유 종목 즉시 갭보정 (봉 버퍼 미확보 시 ATR 계산 즉시 가능하게) + if self.candle_agg: + try: + lim = get_env_int("SHORT_GAP_FILL_LIMIT", 100) + df_gap = self.client.get_minute_chart(code, period="3", limit=lim) + if df_gap is not None and not df_gap.empty: + self.candle_agg.fill_gap_from_rest(code, 3, df_gap) + except Exception as _ge: + logger.debug("매수후 갭보정 실패(%s): %s", code, _ge) + + # 체결 알림 (MM) — API 추가 호출 없이 메모리 값만 사용 + try: + invest_amt = price * qty + cash_after = self.current_cash - invest_amt if self.current_cash >= invest_amt else 0 + mm_msg = ( + f"🟢 **매수 체결** {name} ({code})\n" + f"{price:,.0f}원 × {qty:,}주 | 손절 {stop_price:,.0f}원 / 목표 {target_price:,.0f}원\n" + f"예수금(예상) {cash_after:,.0f}원 | 보유 {len(self.holdings)}종목" + ) + self.send_mm(mm_msg) + except Exception as e: + logger.debug(f"매수 체결 MM 발송 스킵: {e}") + return True + + def execute_sell(self, signal): + """매도 실행""" + code = signal["code"] + name = signal["name"] + qty = signal["qty"] + + if code not in self.holdings: + logger.warning(f"⚠️ [{name}] 보유 종목 아님") + return False + + # ★ 매도 실패 백오프 체크 (영업일 아님·시장 마감 등 일시 오류 반복 방지) + backoff_until = self._sell_backoff.get(code, 0) + if time.time() < backoff_until: + remain_min = (backoff_until - time.time()) / 60 + logger.debug("⏸ [%s(%s)] 매도 백오프 중 — %.0f분 후 재시도", name, code, remain_min) + return False + + # 매도 주문 + success = self.client.sell_market_order(code, qty) + if not success: + # 실패 원인 분석 → 영업일·시장마감 오류면 백오프 등록 + msg_cd = getattr(self.client, "_last_sell_msg_cd", None) or "" + msg1 = getattr(self.client, "_last_sell_msg1", "") or "" + # KIS 비영업일·장마감 오류코드 (40100000=모의 영업일 아님, 40200000=실전 장외시간) + non_biz_codes = {"40100000", "40200000"} + if msg_cd in non_biz_codes or "영업일" in msg1 or "장외" in msg1 or "시장" in msg1: + backoff_sec = get_env_int("SELL_FAILURE_BACKOFF_SEC", 1800) + self._sell_backoff[code] = time.time() + backoff_sec + logger.warning( + "⏸ [%s(%s)] 매도 실패('%s') → %d분 후 재시도 (SELL_FAILURE_BACKOFF_SEC=%d)", + name, code, msg1, backoff_sec // 60, backoff_sec, + ) + return False + + if success: + # 현재가 조회 + price_data = self.client.inquire_price(code) + if price_data: + sell_price = abs(float(price_data.get("stck_prpr", 0))) + else: + sell_price = signal.get("price", 0) + + # 손익 계산 (매도 후 총자산 반영용) + holding = self.holdings.get(code, {}) + buy_price = holding.get("buy_price", sell_price) + profit_val = (sell_price - buy_price) * qty # 손익 금액 + + # DB에서 매도 처리 (strategy 지정 → 꼬리잡기봇 row만 삭제, 스캘핑봇 row 보호) + self.db.close_trade( + code=code, + sell_price=sell_price, + sell_reason=signal['reason'], + strategy="SHORT_ANT_SHAKING", + ) + + del self.holdings[code] + + # 재진입 쿨다운 기록 (REENTRY_COOLDOWN_SEC 동안 같은 종목 재매수 차단) + self.recently_sold[code] = time.time() + + # 매도 후 WebSocket 구독 해제 → 불필요한 데이터 수신 차단 + if self.ws_cache and self.ws_cache.is_active: + self.ws_cache.unsubscribe(code) + + # 🔥 매도 후 예수금 + 총자산 즉시 업데이트 (손익 반영) + self._update_account_light(profit_val=profit_val) + + logger.info(f"💸 [매도 체결] {name} ({code}): {qty}주 ({signal['reason']}, {signal['profit_pct']*100:+.2f}%)") + # 체결 알림 (MM) — 매도 직후 _update_account_light로 갱신된 예수금/총자산 사용 (추가 API 없음) + try: + pct = signal['profit_pct'] * 100 + cum_pnl = self.current_total_asset - self.total_deposit if self.total_deposit else 0 + cum_pct = (cum_pnl / self.total_deposit * 100) if self.total_deposit and self.total_deposit > 0 else 0 + # 당일 손익: 오늘 장 시작 시 총자산 대비 현재 총자산 차이 + day_pnl = self.current_total_asset - self.start_day_asset if self.start_day_asset else 0 + day_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + mm_msg = ( + f"🔴 **매도 체결** {name} ({code})\n" + f"{sell_price:,.0f}원 × {qty:,}주 | {signal['reason']} | 수익률 {pct:+.2f}% (실현 {profit_val:+,.0f}원)\n" + f"예수금 {self.current_cash:,.0f}원 | 총자산 {self.current_total_asset:,.0f}원 | 보유 {len(self.holdings)}종목\n" + f"당일손익 {day_pnl:+,.0f}원 ({day_pct:+.2f}%) | 누적손익 {cum_pnl:+,.0f}원 ({cum_pct:+.2f}%)" + ) + self.send_mm(mm_msg) + except Exception as e: + logger.debug(f"매도 체결 MM 발송 스킵: {e}") + return True + + return False + + def run(self): + """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """비동기 메인 루프 - 백그라운드 태스크 시작 후 동기 매매 루프 실행""" + logger.info("🚀 단타 트레이딩 봇 시작 (비동기 백그라운드 작업 활성화)") + + # 매터모스트 원격 조종 리스너 (!적용 / !설정 → DB 반영 후 reload_config) + try: + from mm_remote import MattermostRemoteController + self.mm_remote = MattermostRemoteController( + server_url=MM_SERVER_URL, + bot_token=MM_BOT_TOKEN, + channel_alias=self.mm_channel, + mm_config_path=MM_CONFIG_FILE, + db=self.db, + ) + self.mm_remote.start() + except Exception as e: + logger.warning("⚠️ MM 원격 조종 리스너 시작 실패: %s", e) + self.mm_remote = None + + # 백그라운드 태스크 시작 + self._universe_task = asyncio.create_task(self._universe_scan_scheduler()) + self._report_task = asyncio.create_task(self._report_scheduler()) + self._asset_task = asyncio.create_task(self._asset_update_scheduler()) + logger.info("✅ 백그라운드 태스크 시작 완료 (유니버스 스캔, 리포트, 자산 업데이트)") + + # 동기 매매 루프는 별도 스레드에서 실행 (메인 이벤트 루프 블로킹 방지) + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, self._sync_trading_loop) + + def _sync_trading_loop(self): + """동기 매매 루프 (메인 로직) - 백그라운드 작업과 분리""" + logger.info("📈 매매 루프 시작 (동기 모드)") + + while True: + try: + # ★ DB 설정 실시간 반영 (재시작 없이 적용) + self.reload_config() + + now = dt.now() + current_date = now.strftime("%Y-%m-%d") + + # 날짜 변경 시 리포트 플래그 리셋 + if current_date != self.today_date: + self.today_date = current_date + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.ai_report_sent = False + self.start_day_asset = 0 + logger.info(f"📅 날짜 변경: {current_date}") + + # 리포트 전송은 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.hour == 13 and now.minute == 0: + # self.send_morning_report() + # self.send_ai_report() + # elif now.hour == 15 and now.minute == 15: + # self.send_closing_report() + # elif now.hour == 15 and now.minute == 35: + # self.send_final_report() + + # 장 상태 체크 + if not self.check_market_status(): + logger.info("⏸ 장 시간 아님 - 대기 중...") + time.sleep(60) + continue + + # 자산 정보 업데이트는 백그라운드 태스크에서 처리하므로 여기서는 제거 + # (기존 코드와의 호환성을 위해 주석 처리) + # if now.minute % 30 == 0: # 30분마다 + # self._update_assets() + + # 매도 신호 체크 (우선) + sell_signals = self.check_sell_signals() + for signal in sell_signals: + self.execute_sell(signal) + + # 매수: 후보 종목을 3분봉으로 실시간 타점 체크 → 그 순간에만 매수 (키움 TAIL_CATCH 방식) + today_str = dt.now().strftime("%Y-%m-%d") + if today_str != self.today_date: + self.today_date = today_str + self.untradable_skip_set.clear() + logger.debug("📅 날짜 변경 -> 매매불가 제외 목록 초기화") + active_count = len(self.holdings) + db_candidates = self.db.get_target_candidates() + + # 신규 후보 WS 구독 + 3분봉 갭보정 (봉 버퍼 미확보 종목 자동 보완) + if db_candidates: + self._sync_subscriptions(db_candidates) + + if db_candidates and active_count < self.max_stocks: + strength_preview = " | 강도순: " + ", ".join( + f"{c.get('name', c.get('code',''))} {c.get('score', 0):.1f}" for c in db_candidates[:5] + ) if db_candidates else "" + logger.info(f"🔍 [매수체크] 후보 {len(db_candidates)}명 순회 (보유 {active_count}/{self.max_stocks}){strength_preview}") + signals_this_turn = 0 + attempts_this_turn = 0 + for db_item in db_candidates: + code = db_item.get("code") or db_item.get("stk_cd", "") + name = db_item.get("name") or db_item.get("stk_nm", code) + if not code or code in self.holdings: + continue + # 매매불가 종목은 당일 재시도 안 함 → 다음 후보로 + if code in self.untradable_skip_set: + continue + # ★ 재진입 쿨다운 체크: 최근 매도 종목은 일정 시간 동안 재매수 차단. + # 손절 직후 즉시 재매수 → 손절 반복 루프를 근본 차단. + reentry_cooldown = get_env_int("REENTRY_COOLDOWN_SEC", 300) + elapsed_since_sell = time.time() - self.recently_sold.get(code, 0) + if elapsed_since_sell < reentry_cooldown: + remaining = int(reentry_cooldown - elapsed_since_sell) + logger.info( + "⏳ [재진입 차단] %s(%s) 매도 후 쿨다운 중 — 남은 시간 %d초/%d초", + name, code, remaining, reentry_cooldown, + ) + continue + signal = self.check_buy_signal_tail_catch(code, name) + if signal: + signals_this_turn += 1 + attempts_this_turn += 1 + logger.info(f"🛒 [매수 시도] {name} ({code}) {attempts_this_turn}건째") + ok = self.execute_buy(signal) + if ok: + logger.info(f"✅ [매수체크] 이번 턴 시그널 {signals_this_turn}건, 시도 {attempts_this_turn}건, 성공 1건") + time.sleep(random.uniform(1, 2)) + break + # 실패(매매불가, 금액0, 예수금 부족, API 오류 등) -> 다음 후보로 순회 + time.sleep(random.uniform(0.3, 0.8)) + continue + time.sleep(random.uniform(0.5, 1.0)) + if signals_this_turn == 0 and db_candidates: + logger.info(f"🔍 [매수체크] 이번 순회 시그널 0건 (조건 통과한 후보 없음) → 다음 턴에 재시도") + elif attempts_this_turn > 0 and len(self.holdings) == active_count: + logger.info(f"🔍 [매수체크] 이번 턴 시그널 {signals_this_turn}건, 시도 {attempts_this_turn}건, 체결 0건 (실패 후 다음 턴)") + elif not db_candidates and active_count == 0: + logger.debug("🔍 [매수] 타겟 0개 (유니버스 스캔 대기 중)") + + # 대기 (너무 길면 매수 타점 놓침 → 2~4초로 단축) + time.sleep(random.uniform(2, 4)) + + except KeyboardInterrupt: + logger.info("⏹ 봇 종료") + # 백그라운드 태스크 취소 + if self._universe_task: + self._universe_task.cancel() + if self._report_task: + self._report_task.cancel() + if self._asset_task: + self._asset_task.cancel() + break + except Exception as e: + logger.error(f"❌ 루프 에러: {e}") + time.sleep(5) + + +if __name__ == "__main__": + logger.info("🚀 KIS Short V3 (tail_engine 기반, 단독 실행)") + bot = ShortTradingBot() # V3: 매수/매도 = tail_engine (백테스트와 동일 규칙) + bot.run() diff --git a/kis_token_manager.py b/kis_token_manager.py new file mode 100644 index 0000000..b279b3e --- /dev/null +++ b/kis_token_manager.py @@ -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") diff --git a/kis_ws.py b/kis_ws.py new file mode 100644 index 0000000..3d2cd4c --- /dev/null +++ b/kis_ws.py @@ -0,0 +1,1545 @@ +""" +kis_ws.py — KIS WebSocket 실시간 체결가 캐시 (H0STCNT0) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +역할: check_sell_signals() 의 inquire_price() REST 폴링을 대체. + 보유 종목 코드를 구독해두면 KIS가 체결마다 push → 즉시 가격 캐시 갱신. + +WebSocket vs REST: + REST : 요청마다 0.5s 딜레이(API 제한) + 응답 대기 → 보유 3종목 ≈ 3초 낭비 + WebSocket: 연결 1회 + push 수신 → 체결 즉시 캐시 갱신, API 카운트 무관 + +KIS 공식 스펙: + TR_ID : H0STCNT0 (국내주식 실시간체결가) + 실전 URL : ws://ops.koreainvestment.com:21000 (KIS_WS_URL_REAL, env/DB 변경 가능) + 모의 URL : ws://ops.koreainvestment.com:31000 (KIS_WS_URL_MOCK, env/DB 변경 가능) + 세션 구독 한도: 최대 41종목 (MAX_STOCKS=3~4 수준에서 문제 없음) + +KIS 2026-02-24 경고 준수 (무한 재연결 차단 정책): + - 재연결 횟수 제한: 1시간 내 MAX_RECONNECTS_PER_HOUR 회 초과 시 자동 대기 + - 최대 총 재연결 횟수: MAX_RECONNECT_ATTEMPTS 회 초과 시 WebSocket 종료 → REST fallback + - 지수 백오프: 5초 → 10초 → 20초 ... 최대 300초 + +모의투자(VTS): + KIS 모의투자는 WebSocket을 지원하지 않는 경우가 많음. + 연결 실패 시 is_active=False → check_sell_signals()가 REST로 자동 fallback. + KIS_WS_MOCK_ENABLED=true (env/DB) 로 강제 활성화 가능. + +설치 필요: + pip install websocket-client +""" + +from __future__ import annotations + +import json +import logging +import queue +import threading +import time +from pathlib import Path +from typing import Dict, Optional, Set + +import requests + +logger = logging.getLogger("KISWebSocket") + + +# ------------------------------------------------------------------ +# 모듈 수준에서 get_env 함수를 참조 (kis_long_ver1 공용 함수 재사용) +# 이 파일이 단독으로도 동작할 수 있도록 fallback import 포함 +# ------------------------------------------------------------------ +try: + from kis_long_ver1 import get_env_from_db, get_env_int, get_env_bool +except ImportError: + try: + from kis_short_ver2 import get_env_from_db, get_env_int, get_env_bool + except ImportError: + # 모듈 임포트 전 단계에서는 기본값만 사용 + def get_env_from_db(key, default=""): # type: ignore[misc] + return default + + def get_env_int(key, default): # type: ignore[misc] + return default + + def get_env_bool(key, default=False): # type: ignore[misc] + return default + + +class KISWebSocketPriceCache: + """ + KIS H0STCNT0 실시간 체결가 WebSocket 수신기. + + 사용법: + ws_cache = KISWebSocketPriceCache(app_key, app_secret, is_mock=False) + ok = ws_cache.start() # 백그라운드 스레드 시작 + ws_cache.subscribe("005930") # 종목 구독 + data = ws_cache.get_price("005930") # inquire_price 호환 dict 반환 + ws_cache.stop() + + get_price() 반환 값이 None 이면 → REST inquire_price() 로 fallback + """ + + # H0STCNT0 데이터 필드 인덱스 ('^' 구분) + # KIS H0STCNT0 국내주식 실시간체결 전문 순서 (0-based) + IDX_CODE = 0 # MKSC_SHRN_ISCD: 유가증권 단축 종목코드 + IDX_TIME = 1 # STCK_CNTG_HOUR: 체결 시간 + IDX_PRICE = 2 # STCK_PRPR: 주식 현재가 (체결가) + IDX_SIGN = 3 # PRDY_VRSS_SIGN: 전일 대비 부호 + IDX_CHANGE = 4 # PRDY_VRSS: 전일 대비 + IDX_CHGPCT = 5 # PRDY_CTRT: 전일 대비율 + # 당일 시고저 (매수 체크 시 REST inquire_price 대체용 → API 과부하 방지) + IDX_OPEN = 7 # STCK_OPRC: 주식 시가 (당일) + IDX_HIGH = 8 # STCK_HGPR: 주식 고가 (당일) + IDX_LOW = 9 # STCK_LWPR: 주식 저가 (당일) + IDX_VOLUME = 11 # ACML_VOL: 누적 거래량 (참고용) + + # 재연결 정책 (KIS 2026-02-24 경고 준수) + MAX_RECONNECT_ATTEMPTS = 10 # 총 재연결 최대 횟수 (STABLE_CONN_RESET_SEC 이상 안정 연결 후 끊기면 초기화) + MAX_RECONNECTS_PER_HOUR = 6 # 1시간 내 재연결 허용 횟수 + RECONNECT_BASE_DELAY_SEC = 5.0 # 초기 재연결 대기(초) + RECONNECT_MAX_DELAY_SEC = 300.0 # 최대 재연결 대기(초) + # 이 시간(초) 이상 안정적으로 연결이 유지됐다가 끊기면 카운터를 초기화. + # 예: 5분 이상 정상 운영 후 네트워크 일시 장애 → '버스트 차단'이 아닌 '정상 재연결'로 간주. + STABLE_CONN_RESET_SEC = 300.0 # 5분 + + # Approval key 유효시간 (23시간, KIS REST 토큰과 별개) + APPROVAL_KEY_CACHE_SEC = 82800 + + def __init__(self, app_key: str, app_secret: str, is_mock: bool = True): + self.app_key = app_key + self.app_secret = app_secret + self.is_mock = is_mock + + # WebSocket URL (env/DB 로 재정의 가능 → 연결 실패 시 사용자가 수정) + _default_real = "ws://ops.koreainvestment.com:21000" + _default_mock = "ws://ops.koreainvestment.com:31000" + self._ws_url = ( + get_env_from_db("KIS_WS_URL_MOCK", _default_mock) + if is_mock + else get_env_from_db("KIS_WS_URL_REAL", _default_real) + ) + self._base_url = ( + "https://openapivts.koreainvestment.com:29443" + if is_mock + else "https://openapi.koreainvestment.com:9443" + ) + + # ── 가격 캐시 ────────────────────────────────────────────── + # { code: {"data": dict, "ts": float} } + self._cache: Dict[str, Dict] = {} + self._cache_lock = threading.Lock() + + # ── 구독 목록 ────────────────────────────────────────────── + self._subscribed: Set[str] = set() + self._sub_lock = threading.Lock() + + # ── 영구 구독 목록 (홀딩 관심종목 등) ────────────────────── + # unsubscribe() 호출에도 해제되지 않는 고정 구독 코드 + self._permanent_codes: Set[str] = set() + self._load_permanent_watchlist() + + # ── WebSocket 관련 ───────────────────────────────────────── + self._ws = None # 현재 WebSocketApp 인스턴스 + self._ws_thread: Optional[threading.Thread] = None + self._running = False + self._connected = False + + # ── approval_key (REST 토큰과 별개, WebSocket 전용 인증) ─── + self._approval_key: Optional[str] = None + self._approval_key_ts: float = 0.0 + + # ── 재연결 관리 ──────────────────────────────────────────── + self._reconnect_count = 0 + self._reconnect_times: list = [] # 최근 재연결 타임스탬프 목록 + self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC + self._last_connect_time: float = 0.0 # 마지막 연결 성공 시각 (안정 연결 판단용) + + # ── CandleAggregator (스캘핑봇 연동 시 외부에서 주입) ───── + # attach_candle_aggregator(agg) 로 연결, None이면 봉 집계 비활성 + self._candle_agg: Optional["CandleAggregator"] = None + + # ── WS 연결 성공 시 갭보정 콜백 ─────────────────────────── + # set_on_connected_callback(fn) 으로 등록. + # 연결 성공(_on_open) 후 별도 스레드에서 호출됨. + # 장 시간일 때만 실행 (새벽 재연결 시 API 빈 응답 방지) + self._on_connected_callback: Optional[callable] = None + + # ── websocket-client 사용 가능 여부 ─────────────────────── + try: + import websocket as _ws_lib + self._ws_lib = _ws_lib + self._available = True + except ImportError: + self._ws_lib = None + self._available = False + logger.warning( + "⚠️ websocket-client 미설치 → WebSocket 실시간 가격 비활성.\n" + " pip install websocket-client 를 실행하세요." + ) + + # ================================================================== + # Public API + # ================================================================== + + def attach_candle_aggregator(self, agg: "CandleAggregator") -> None: + """ + CandleAggregator 를 연결합니다. + 연결 후부터 틱 수신 시 자동으로 agg.on_tick() 이 호출됩니다. + kis_scalping_ver1.py 에서 ws_cache.attach_candle_aggregator(agg) 로 호출. + """ + self._candle_agg = agg + logger.info("✅ CandleAggregator 연결 완료 (봉 집계 활성화)") + + def set_on_connected_callback(self, fn) -> None: + """ + WS 연결 성공(_on_open) 시 호출할 콜백 등록. + ScalpingBotV1._fill_all_gaps 를 넘겨서 매 연결 시 갭보정 자동 실행. + 장 시간일 때만 콜백을 실행 (새벽 자동재연결 시 API 빈 응답 방지). + """ + self._on_connected_callback = fn + + def start(self, force_cleanup: bool = True) -> bool: + """ + WebSocket 수신 백그라운드 스레드를 시작합니다. + 성공 시 True, 사용 불가(모의/패키지 미설치/키 없음) 시 False. + False 를 반환해도 봇은 REST fallback으로 정상 동작합니다. + """ + if not self._available: + return False + + # 모의투자는 기본 비활성 (KIS 모의 서버 WebSocket 미지원 가능성) + # KIS_WS_MOCK_ENABLED=true 로 강제 활성화 가능 + if self.is_mock and not get_env_bool("KIS_WS_MOCK_ENABLED", False): + logger.info("ℹ️ 모의투자: WebSocket 기본 비활성 (KIS_WS_MOCK_ENABLED=true 로 활성 가능)") + return False + + if self._running: + return True + + # ── [CRITICAL] 비정상 종료 후 재시작 대비: 이전 세션 강제 정리 ────────────── + # force_cleanup=True 시: + # 1. approval_key 새로 발급 (이전 세션 무효화) + # 2. _subscribed 세트 비우기 (메모리 정리) + # 3. _cache 비우기 (오래된 데이터 제거) + # KIS 서버는 연결이 끊기면 5~10 분 내 자동 구독 해제되므로, + # 새 approval_key 로 새 세션을 열면 이전 구독은 자동 소멸. + if force_cleanup: + logger.info("🧹 WebSocket 세션 초기화 (비정상 종료 대비)") + self._approval_key = None # 이전 approval_key 무효화 + self._approval_key_ts = 0.0 + with self._sub_lock: + self._subscribed.clear() # 구독 목록 초기화 + with self._cache_lock: + self._cache.clear() # 캐시 초기화 + logger.info("✅ WebSocket 세션 초기화 완료 (구독/캐시 리셋)") + + # approval_key 발급 (연결 전 확인) + if not self._get_approval_key(): + logger.warning("⚠️ WebSocket approval_key 발급 실패 → REST fallback 모드") + return False + + self._running = True + self._ws_thread = threading.Thread( + target=self._run_ws_loop, daemon=True, name="KIS-WS-H0STCNT0" + ) + self._ws_thread.start() + logger.info( + "✅ KIS WebSocket 수신 스레드 시작 (H0STCNT0 | url=%s)", self._ws_url + ) + return True + + def stop(self, clear_subscriptions: bool = False) -> None: + """ + WebSocket 수신 중단 및 스레드 종료. + + Args: + clear_subscriptions: True 시 모든 종목 구독 해제 후 종료 + (봇 정상 종료 시 KIS 서버 정리용) + """ + # 옵션: 모든 구독 해제 (KIS 서버 정리용) + if clear_subscriptions and self._connected and self._ws: + with self._sub_lock: + codes = list(self._subscribed) + for code in codes: + self._send_sub_msg(code, subscribe=False) + with self._sub_lock: + self._subscribed.discard(code) + with self._cache_lock: + self._cache.pop(code, None) + logger.info("📡 WebSocket 구독 해제: %s", code) + logger.info("✅ 모든 WebSocket 구독 정리 완료 (%d종목)", len(codes)) + + self._running = False + self._connected = False + if self._ws: + try: + self._ws.close() + except Exception: + pass + if self._ws_thread and self._ws_thread.is_alive(): + self._ws_thread.join(timeout=5) + logger.info("🛑 KIS WebSocket 종료") + + # KIS WebSocket 세션 당 구독 가능 최대 종목 수 + # 초과 시 서버에서 오류 반환 또는 계정 일시 차단 가능 (KIS 공지 준수) + MAX_SUBSCRIPTIONS = 41 + + def _load_permanent_watchlist(self) -> None: + """ + long_term_watchlist.json 에 있는 홀딩 관심종목을 영구 구독 목록으로 로드. + WS 연결 시 자동으로 subscribe(), unsubscribe() 호출 시에도 해제하지 않음. + """ + try: + wl_path = Path(__file__).resolve().parent / "long_term_watchlist.json" + if not wl_path.exists(): + return + items = json.loads(wl_path.read_text(encoding="utf-8")).get("items", []) + codes = [i["code"] for i in items if i.get("code")] + self._permanent_codes = set(codes) + if codes: + logger.info( + "📌 홀딩 영구 구독 목록 로드: %s (%d종목)", + ", ".join(codes), len(codes), + ) + except Exception as e: + logger.warning("영구 구독 목록 로드 실패: %s", e) + + def subscribe(self, code: str) -> None: + """ + 실시간 체결가 구독 등록. + 이미 연결 중이면 즉시 구독 메시지 전송, 연결 전이면 연결 성공 시 일괄 등록. + KIS 세션 한도(MAX_SUBSCRIPTIONS=41) 초과 시 등록 거부 후 경고 로그 출력. + """ + code = (code or "").strip() + if not code: + return + with self._sub_lock: + if code in self._subscribed: + return # 이미 구독 중 → 중복 전송 방지 + if len(self._subscribed) >= self.MAX_SUBSCRIPTIONS: + logger.warning( + "⚠️ WebSocket 구독 한도 초과(%d/%d) → %s 구독 거부 " + "(KIS 세션 한도 준수: 불필요 종목 구독해제 후 재시도)", + len(self._subscribed), self.MAX_SUBSCRIPTIONS, code, + ) + return + self._subscribed.add(code) + if self._connected and self._ws: + self._send_sub_msg(code, subscribe=True) + logger.info("📡 WebSocket 구독 추가: %s (%d/%d)", code, len(self._subscribed), self.MAX_SUBSCRIPTIONS) + + def unsubscribe(self, code: str) -> None: + """ + 실시간 체결가 구독 해제 및 캐시 삭제. + 단, long_term_watchlist.json 의 영구 구독 종목은 해제하지 않음. + """ + code = (code or "").strip() + if code in self._permanent_codes: + logger.debug("📌 영구 구독 종목 해제 요청 무시: %s (홀딩 관심종목)", code) + return + with self._sub_lock: + self._subscribed.discard(code) + with self._cache_lock: + self._cache.pop(code, None) + if self._connected and self._ws: + self._send_sub_msg(code, subscribe=False) + logger.info("📡 WebSocket 구독 해제: %s", code) + + def get_price(self, code: str, max_age_sec: float = 5.0) -> Optional[Dict]: + """ + WebSocket 캐시에서 실시간 가격 + 당일 시고저를 꺼냅니다. + max_age_sec 초 이내 수신된 체결 틱만 유효하게 취급합니다. + + 반환 형식: { + "stck_prpr": "73900", # 현재가 (체결가, REST inquire_price와 동일) + "prdy_vrss": "200", # 전일 대비 + "prdy_ctrt": "0.27", # 전일 대비율 + "stck_oprc": "73000", # 당일 시가 (REST와 동일) + "stck_hgpr": "74500", # 당일 고가 (REST와 동일) + "stck_lwpr": "72800", # 당일 저가 (REST와 동일) + } + 반환 None: WebSocket 미연결 / 데이터 없음 / max_age_sec 초 초과 → REST fallback 권장 + + ※ WS 캐시 정확도: H0STCNT0 체결 틱마다 KIS 서버가 시고저를 함께 전송 + → 장중 REST inquire_price와 동일하며 지연이 없음 (REST 왕복 100~300ms 생략) + """ + if not self._available or not self._connected: + return None + with self._cache_lock: + entry = self._cache.get(code) + if not entry: + return None + age = time.time() - entry.get("ts", 0) + if age > max_age_sec: + # 데이터가 너무 오래됨 → REST로 재확인 + return None + return entry.get("data") + + @property + def is_active(self) -> bool: + """WebSocket이 연결되어 실시간 데이터를 수신 중이면 True.""" + return bool(self._available and self._connected and self._running) + + # ================================================================== + # 내부 메서드 + # ================================================================== + + def _get_approval_key(self) -> Optional[str]: + """ + WebSocket 전용 approval_key 발급. + REST 액세스 토큰과 완전히 별개 (endpoint: /oauth2/Approval). + """ + now = time.time() + if self._approval_key and (now - self._approval_key_ts) < self.APPROVAL_KEY_CACHE_SEC: + return self._approval_key + try: + url = f"{self._base_url}/oauth2/Approval" + body = { + "grant_type": "client_credentials", + "appkey": self.app_key, + "secretkey": self.app_secret, + } + r = requests.post(url, json=body, timeout=10) + data = r.json() + key = data.get("approval_key") + if key: + self._approval_key = key + self._approval_key_ts = now + logger.info( + "✅ WebSocket approval_key 발급 완료 (앞8자: %s…)", key[:8] + ) + return key + logger.error("❌ WebSocket approval_key 발급 실패: %s", data) + except Exception as e: + logger.error("❌ WebSocket approval_key 요청 예외: %s", e) + return None + + def _build_sub_payload(self, code: str, subscribe: bool) -> str: + """구독(tr_type=1) / 해제(tr_type=2) JSON 메시지 생성.""" + return json.dumps({ + "header": { + "approval_key": self._approval_key or "", + "custtype": "P", + "tr_type": "1" if subscribe else "2", + "content-type": "utf-8", + }, + "body": { + "input": { + "tr_id": "H0STCNT0", + "tr_key": code, + } + }, + }) + + def _send_sub_msg(self, code: str, subscribe: bool = True) -> None: + """WebSocket으로 구독/해제 메시지 전송. 실패 시 조용히 무시.""" + if not self._ws: + return + try: + self._ws.send(self._build_sub_payload(code, subscribe)) + except Exception as e: + logger.debug("구독 메시지 전송 실패(%s): %s", code, e) + + def _parse_realtime_msg(self, raw: str) -> None: + """ + H0STCNT0 실시간 체결가 메시지 파싱 및 캐시 갱신. + + 정상 포맷: "0|H0STCNT0|001|005930^082317^73900^5^200^0.27^..." + parts[0]: 암호화구분 (0=평문, 1=암호화) + parts[1]: TR_ID + parts[2]: 건수 + parts[3]: 데이터 ('^' 구분) + + KIS PINGPONG: 메시지가 "PINGPONG" 문자열 → 동일하게 echoing. + JSON 응답(구독 확인/에러): {"header":{...},"body":{...}} → 무시. + """ + if not raw: + return + + # ── KIS Application-Level PINGPONG ───────────────────────── + if raw.strip() == "PINGPONG": + if self._ws: + try: + self._ws.send("PINGPONG") + except Exception: + pass + return + + # ── 구독 응답(JSON) 무시 ──────────────────────────────────── + if raw.startswith("{"): + try: + j = json.loads(raw) + header = j.get("header", {}) + if header.get("tr_id") == "H0STCNT0": + body = j.get("body", {}) + rt = body.get("rt_cd", "") + msg = body.get("msg1", "") + logger.debug("H0STCNT0 구독 응답: rt_cd=%s msg=%s", rt, msg) + except Exception: + pass + return + + # ── 실시간 데이터 파싱 ────────────────────────────────────── + parts = raw.split("|") + if len(parts) < 4: + return + # parts[0]=암호화구분, parts[1]=TR_ID, parts[2]=건수, parts[3]=데이터 + if parts[1] != "H0STCNT0": + return + + # 암호화된 데이터는 아직 미지원 (평문만 처리) + if parts[0] == "1": + logger.debug("H0STCNT0 암호화 데이터 수신 (처리 스킵) → REST fallback 권장") + return + + # 한 메시지에 여러 건이 포함될 수 있음 (parts[2] = 건수) + # 단순히 parts[3] 전체를 파싱 (단건 기준) + fields = parts[3].split("^") + if len(fields) <= max(self.IDX_PRICE, self.IDX_CHGPCT): + return + + try: + code = fields[self.IDX_CODE].strip() + price = float(fields[self.IDX_PRICE]) + if not code or price <= 0: + return + + chg_raw = fields[self.IDX_CHANGE] if len(fields) > self.IDX_CHANGE else "0" + pct_raw = fields[self.IDX_CHGPCT] if len(fields) > self.IDX_CHGPCT else "0.00" + # 당일 시고저: REST inquire_price 대체용 (매수 체크 시 API 과부하 방지) + open_raw = fields[self.IDX_OPEN] if len(fields) > self.IDX_OPEN else "0" + high_raw = fields[self.IDX_HIGH] if len(fields) > self.IDX_HIGH else "0" + low_raw = fields[self.IDX_LOW] if len(fields) > self.IDX_LOW else "0" + + # inquire_price output 딕셔너리와 키 이름을 맞춤 + # stck_oprc/hgpr/lwpr 도 함께 저장 → kis_short_ver2 매수 체크에서 REST 없이 활용 + data_compat = { + "stck_prpr": str(int(price)), # 현재가 (int 문자열) + "prdy_vrss": chg_raw, # 전일 대비 + "prdy_ctrt": pct_raw, # 전일 대비율 + "stck_oprc": open_raw, # 당일 시가 + "stck_hgpr": high_raw, # 당일 고가 + "stck_lwpr": low_raw, # 당일 저가 + } + + with self._cache_lock: + self._cache[code] = {"data": data_compat, "ts": time.time()} + + # ── CandleAggregator 연동: 틱 → 봉 집계 (스캘핑봇 전용, 연결 시 활성화) + if self._candle_agg is not None: + tick_time = fields[self.IDX_TIME].strip() if len(fields) > self.IDX_TIME else "" + vol_raw = fields[self.IDX_VOLUME].strip() if len(fields) > self.IDX_VOLUME else "0" + try: + tick_vol = int(vol_raw) + except ValueError: + tick_vol = 0 + self._candle_agg.on_tick(code, price, tick_vol, tick_time) + + logger.debug("H0STCNT0 수신: %s → %s원", code, int(price)) + + except (ValueError, IndexError) as e: + logger.debug("H0STCNT0 파싱 오류: %s | raw=%s", e, raw[:80]) + + # ================================================================== + # WebSocket 루프 (백그라운드 스레드) + # ================================================================== + + def _is_market_hours(self) -> bool: + """ + KIS WebSocket 서비스 시간 여부. + - 실전투자: 08:25~ (장전 호가 포함) + - 모의투자: 09:00~ (모의 WS 서버가 09:00 이전 연결 즉시 거부) + False 반환 시 재연결 시도 없이 다음 장 시작까지 대기. + """ + import datetime as _dt + now = _dt.datetime.now() + if now.weekday() >= 5: # 토·일 + return False + # 모의투자는 09:00, 실전투자는 08:25 부터 서버 응답 + open_h, open_m = (9, 0) if self.is_mock else (8, 25) + return _dt.time(open_h, open_m) <= now.time() <= _dt.time(16, 5) + + def _seconds_until_market_open(self) -> float: + """다음 장 시작까지 남은 초. 최소 60초 반환. + - 실전투자: 08:25 기준 + - 모의투자: 09:00 기준 + """ + import datetime as _dt + now = _dt.datetime.now() + open_h, open_m = (9, 0) if self.is_mock else (8, 25) + target = now.replace(hour=open_h, minute=open_m, second=0, microsecond=0) + if now.time() >= _dt.time(16, 5): + # 오늘 장 마감 → 내일 장 시작 + target += _dt.timedelta(days=1) + # 주말 건너뛰기 + while target.weekday() >= 5: + target += _dt.timedelta(days=1) + return max(60.0, (target - now).total_seconds()) + + def _run_ws_loop(self) -> None: + """ + 재연결 정책을 포함한 WebSocket 메인 루프. + run_forever() 가 반환(연결 끊김)되면 지수 백오프 후 재연결 시도. + KIS 정책: 1시간 내 MAX_RECONNECTS_PER_HOUR 회 초과 시 강제 대기. + 장외 시간(16:05~08:25, 주말): 재연결 없이 다음 장까지 대기. + """ + # 즉시 끊김(장외 서버 거부) 감지: INSTANT_DROP_SEC 이내 끊김이 N회 연속이면 장외 슬립 + INSTANT_DROP_SEC = 3.0 # 연결 후 이 초 이내에 끊기면 "즉시 종료"로 판정 + INSTANT_DROP_MAX = 3 # 연속 N회 즉시 종료 → 장외 슬립으로 전환 + _instant_drop_streak = 0 # 연속 즉시 종료 카운터 + + while self._running: + now = time.time() + + # ── 장외 시간이면 다음 장 시작까지 대기 ───────────────────── + if not self._is_market_hours(): + wait_sec = self._seconds_until_market_open() + logger.info( + "🌙 장외 시간 — WebSocket 재연결 중지, 다음 장 시작까지 %.0f분 대기", + wait_sec / 60, + ) + # 재연결 카운터·백오프 초기화 (장 시작 시 깨끗하게 재접속) + self._reconnect_count = 0 + self._reconnect_times = [] + self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC + _instant_drop_streak = 0 + # 60초 단위로 쪼개서 슬립 (stop() 신호 빠르게 감지) + for _ in range(int(wait_sec // 60)): + if not self._running: + return + time.sleep(60) + time.sleep(wait_sec % 60) + continue + + # ── 연속 즉시 종료 감지 → 장외 서버 거부로 간주 ───────────── + # 연결 직후 INSTANT_DROP_SEC 이내에 끊기는 패턴이 N회 반복되면 + # 서버가 장외이거나 앱키가 차단된 것으로 간주하고 긴 슬립 진입 + if _instant_drop_streak >= INSTANT_DROP_MAX: + wait_sec = self._seconds_until_market_open() + logger.warning( + "⚠️ WebSocket 연속 즉시 끊김 %d회 감지 (장외 서버 거부 추정) " + "→ %.0f분 대기 후 재시도 (KIS 차단 방지)", + _instant_drop_streak, wait_sec / 60, + ) + self._reconnect_count = 0 + self._reconnect_times = [] + self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC + _instant_drop_streak = 0 + for _ in range(int(wait_sec // 60)): + if not self._running: + return + time.sleep(60) + time.sleep(wait_sec % 60) + continue + + # ── 안정 연결 후 끊김이면 재연결 카운터 초기화 ─────────── + # STABLE_CONN_RESET_SEC(5분) 이상 정상 운영 후 끊겼다면 + # 이전 재연결 이력을 소거하여 'KIS 정상 케이스'로 처리. + # (수개월 운영 중 산발적 네트워크 단절에 의해 10회 소진 → 영구 비활성화 방지) + if ( + self._last_connect_time > 0 + and (now - self._last_connect_time) > self.STABLE_CONN_RESET_SEC + and self._reconnect_count > 0 + ): + logger.info( + "♻️ WebSocket 안정 운영(%.0f분) 후 끊김 → 재연결 카운터 초기화 (%d→0)", + (now - self._last_connect_time) / 60, + self._reconnect_count, + ) + self._reconnect_count = 0 + self._reconnect_times = [] + self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC + + # ── 1시간 내 재연결 횟수 점검 ──────────────────────────── + self._reconnect_times = [t for t in self._reconnect_times if now - t < 3600] + + if len(self._reconnect_times) >= self.MAX_RECONNECTS_PER_HOUR: + # 가장 오래된 재연결로부터 1시간이 지날 때까지 대기 + wait = max(10, 3600 - (now - self._reconnect_times[0]) + 10) + logger.warning( + "⛔ 1시간 내 WebSocket 재연결 %d회 초과 → %.0f초(%.0f분) 강제 대기 (KIS 차단 방지)", + self.MAX_RECONNECTS_PER_HOUR, wait, wait / 60, + ) + time.sleep(wait) + continue + + # ── 총 재연결 횟수 초과 → REST fallback 전환 ───────────── + if self._reconnect_count >= self.MAX_RECONNECT_ATTEMPTS: + logger.error( + "❌ WebSocket 최대 재연결 %d회 초과 → WebSocket 종료, REST fallback 전환", + self.MAX_RECONNECT_ATTEMPTS, + ) + self._running = False + break + + # ── approval_key 갱신 ───────────────────────────────────── + approval_key = self._get_approval_key() + if not approval_key: + logger.warning( + "WebSocket approval_key 발급 불가 → %ds 대기 후 재시도", + int(self._reconnect_delay), + ) + time.sleep(self._reconnect_delay) + self._reconnect_delay = min( + self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC + ) + continue + + # ── 재연결 카운트 및 대기 ───────────────────────────────── + self._reconnect_count += 1 + if self._reconnect_count > 1: + self._reconnect_times.append(time.time()) + logger.info( + "🔄 WebSocket 재연결 시도 %d/%d (대기 %ds 완료)", + self._reconnect_count, + self.MAX_RECONNECT_ATTEMPTS, + int(self._reconnect_delay), + ) + time.sleep(self._reconnect_delay) + self._reconnect_delay = min( + self._reconnect_delay * 2, self.RECONNECT_MAX_DELAY_SEC + ) + + # ── WebSocketApp 생성 및 실행 ───────────────────────────── + _conn_start = time.time() # 연결 시작 시각 (즉시 종료 감지용) + try: + ws_app = self._ws_lib.WebSocketApp( + self._ws_url, + on_open = self._on_open, + on_message = self._on_message, + on_error = self._on_error, + on_close = self._on_close, + ) + self._ws = ws_app + # ping_interval: WebSocket 프로토콜 레벨 PING (연결 유지) + # KIS Application-Level PINGPONG은 _parse_realtime_msg 에서 처리 + ws_app.run_forever(ping_interval=20, ping_timeout=10) + except Exception as e: + logger.error("WebSocket run_forever 예외: %s", e) + + if not self._running: + break + + # ── 즉시 종료 여부 판정 (장외 서버 거부 감지) ──────────────── + _conn_duration = time.time() - _conn_start + if _conn_duration < INSTANT_DROP_SEC: + _instant_drop_streak += 1 + logger.debug( + "⚡ WebSocket 즉시 종료 감지 (%.1f초, streak=%d/%d)", + _conn_duration, _instant_drop_streak, INSTANT_DROP_MAX, + ) + else: + # 어느 정도 연결 유지됐다면 즉시 종료 streak 초기화 + _instant_drop_streak = 0 + + logger.info("KIS WebSocket 루프 종료 (is_active=False, REST fallback 전환)") + + # ------------------------------------------------------------------ + # WebSocket 콜백 + # ------------------------------------------------------------------ + + def _on_open(self, ws) -> None: + """연결 성공: 등록된 모든 종목 구독 요청 전송.""" + self._connected = True + self._reconnect_delay = self.RECONNECT_BASE_DELAY_SEC # 대기 시간 리셋 + self._last_connect_time = time.time() # 안정 연결 판단용 타임스탬프 기록 + + logger.info("✅ KIS WebSocket 연결 성공 (H0STCNT0 | url=%s)", self._ws_url) + + # 영구 구독 목록(홀딩 관심종목) 먼저 구독 등록 + for code in sorted(self._permanent_codes): + with self._sub_lock: + if code not in self._subscribed: + if len(self._subscribed) < self.MAX_SUBSCRIPTIONS: + self._subscribed.add(code) + else: + logger.warning("⚠️ 구독 한도로 영구구독 추가 불가: %s", code) + continue + self._send_sub_msg(code, subscribe=True) + if self._permanent_codes: + logger.info("📌 영구 구독 등록 완료: %d종목", len(self._permanent_codes)) + + # 재연결 시에도 구독 목록 재등록 + with self._sub_lock: + codes = set(self._subscribed) + for code in sorted(codes): + self._send_sub_msg(code, subscribe=True) + if codes: + logger.info("📡 WebSocket 구독 일괄 등록: %s", ", ".join(sorted(codes))) + + # ── 연결 성공 시 갭보정 콜백 (장 시간일 때만) ─────────────── + # 봇 시작 후 처음 WS가 안정 연결되는 시점(9:00 이후)에 + # REST 분봉 데이터로 DB를 채워 '봉부족' 문제 해소. + # 새벽 재연결 시에는 is_market_hours() False → 콜백 스킵. + if self._on_connected_callback and self._is_market_hours(): + try: + cb_thread = threading.Thread( + target=self._on_connected_callback, + name="WS-GapFill", + daemon=True, + ) + cb_thread.start() + except Exception as e: + logger.debug("갭보정 콜백 실행 실패: %s", e) + + def _on_message(self, ws, message: str) -> None: + self._parse_realtime_msg(message) + + def _on_error(self, ws, error) -> None: + self._connected = False + logger.warning("⚠️ KIS WebSocket 오류: %s", error) + + def _on_close(self, ws, close_status_code, close_msg) -> None: + self._connected = False + logger.info( + "🔌 KIS WebSocket 연결 종료 (code=%s msg=%s)", + close_status_code, close_msg or "", + ) + + +# ====================================================================== +# ====================================================================== +# CandleAggregator — WebSocket 틱 → OHLCV 봉 실시간 집계기 +# ====================================================================== +class CandleAggregator: + """ + KISWebSocketPriceCache 에서 수신한 틱을 N분봉으로 집계합니다. + + Two-Track 아키텍처 (Gemini/퀀트 펌 방식) + ───────────────────────────────────────── + [트랙 1 — 매매 두뇌 (논블로킹)] + WebSocket 틱 → on_tick() → RAM에서 OHLCV 즉시 갱신 + → 매수/매도 판단은 get_latest_confirmed() 등 메모리 접근만 사용 + → DB 대기 시간 0ms, 타점 놓침 없음 + + [트랙 2 — 기록원 스레드 (백그라운드)] + 봉 확정 시 dict를 Queue에 put_nowait() (논블로킹, 0.000001초) + → 백그라운드 스레드(_db_writer)가 BATCH_SIZE개 or FLUSH_INTERVAL초마다 + DB에 executemany() 한 방에 묶어서 INSERT + → 백테스트용 봉 데이터 완전 보존, 매매 루프 블로킹 없음 + + 진행 중 봉(is_confirmed=0) 처리 + ───────────────────────────────── + - RAM의 _current 버퍼에만 존재 → DB에 절대 쓰지 않음 + - 매수 루프는 get_current_candle() 로 즉시 메모리 접근 + - 봉 확정(분 바뀜) 순간에만 Queue → DB 기록 + + RSI 계산 + ──────── + - 확정 봉 close 리스트(_closes, 최대 MAX_CLOSE_BUFFER개) RAM에 유지 + - RSI(2/3/5) 계산은 순수 Python 연산, DB 조회 없음 + - 봉 수 부족 시 RSI=None → 매수 루프에서 신호 무시 + + 스레드 안전성 + ───────────── + - _lock : on_tick / fill_gap 간 경합 방지 (RAM 버퍼 보호) + - Queue : thread-safe, put_nowait 는 lock 불필요 + - _db_writer : 독립 daemon 스레드 (봇 종료 시 자동 소멸) + """ + + MAX_CLOSE_BUFFER = 200 # RSI 계산용 close 보관 최대 개수 + BATCH_SIZE = 50 # 이 개수 이상 쌓이면 즉시 배치 플러시 + FLUSH_INTERVAL = 2.0 # 초 — BATCH_SIZE 미달이라도 이 주기로 플러시 + + def __init__(self, db=None, timeframes: list = None): + """ + Args: + db : TradeDB 인스턴스. None이면 DB 쓰기 비활성(순수 RAM 모드). + timeframes: 집계할 봉 단위 리스트 (기본 [1, 3]) + """ + self.db = db + self.timeframes: list = timeframes if timeframes else [1, 3] + self._lock = threading.Lock() + + # ── 트랙 1: RAM 버퍼 ───────────────────────────────────── + # 진행 중인 봉: { (code, tf): {candle_time, open, high, low, close, volume} } + self._current: Dict[tuple, dict] = {} + # RSI 계산용 확정 봉 close 가격 히스토리: { (code, tf): [c1, c2, ...] } + self._closes: Dict[tuple, list] = {} + # 최근 확정 봉 보관 (get_latest_confirmed / get_candles 용): { (code, tf): [dict, ...] } + self._confirmed: Dict[tuple, list] = {} + + # ── 트랙 2: 비동기 DB 배치 큐 ──────────────────────────── + # 봉 확정 시 dict를 여기 넣으면 _db_writer 가 배치로 INSERT + self._write_queue: queue.Queue = queue.Queue(maxsize=10000) + self._db_writer_thread: Optional[threading.Thread] = None + if self.db is not None: + self._start_db_writer() + + logger.info("✅ CandleAggregator 초기화 완료 (timeframes=%s, db=%s)", + self.timeframes, "활성" if self.db else "비활성(RAM 전용)") + + # ------------------------------------------------------------------ + # 트랙 2: 백그라운드 DB 배치 기록원 + # ------------------------------------------------------------------ + + def _start_db_writer(self) -> None: + """백그라운드 DB 배치 기록 스레드를 시작합니다.""" + self._db_writer_thread = threading.Thread( + target=self._db_writer_loop, + name="CandleDBWriter", + daemon=True, # 봇 종료 시 자동 소멸 + ) + self._db_writer_thread.start() + logger.info("✅ CandleAggregator DB 기록원 스레드 시작 (배치=%d, 주기=%.1fs)", + self.BATCH_SIZE, self.FLUSH_INTERVAL) + + def _db_writer_loop(self) -> None: + """ + 배치 기록 루프. + - Queue에서 BATCH_SIZE개 모이면 즉시 배치 INSERT + - BATCH_SIZE 미달이라도 FLUSH_INTERVAL초마다 플러시 + """ + batch: list = [] + last_flush = time.time() + + while True: + try: + # FLUSH_INTERVAL 내에서 최대한 많이 모아 배치 구성 + timeout = max(0.1, self.FLUSH_INTERVAL - (time.time() - last_flush)) + item = self._write_queue.get(timeout=timeout) + if item is None: + # None = 종료 신호 + break + batch.append(item) + self._write_queue.task_done() + except queue.Empty: + pass + + now = time.time() + should_flush = len(batch) >= self.BATCH_SIZE or \ + (batch and (now - last_flush) >= self.FLUSH_INTERVAL) + + if should_flush and batch: + self._flush_batch(batch) + batch = [] + last_flush = now + + # 루프 종료 시 남은 배치 처리 + if batch: + self._flush_batch(batch) + logger.info("CandleDBWriter 스레드 종료") + + def _flush_batch(self, batch: list) -> None: + """ + 배치 리스트를 DB에 한 번의 executemany 로 INSERT. + 각 item: {code, tf, candle_time, open, high, low, close, volume, + is_confirmed, source, rsi_2, rsi_3, rsi_5} + """ + if not self.db or not batch: + return + try: + import datetime as _dt + now_str = _dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + rows = [] + for item in batch: + rows.append(( + item["code"], + item["tf"], + item["candle_time"], + item["open"], + item["high"], + item["low"], + item["close"], + item.get("volume", 0), + item.get("rsi_2"), + item.get("rsi_3"), + item.get("rsi_5"), + item.get("is_confirmed", 1), + item.get("source", "ws"), + now_str, + )) + # ws_candles 테이블이 존재하면 배치 INSERT (없으면 조용히 skip) + self.db.conn.execute( + """ + INSERT INTO ws_candles + (code, timeframe, candle_time, `open`, high, low, close, + volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + `open`=VALUES(`open`), high=VALUES(high), low=VALUES(low), + close=VALUES(close), volume=VALUES(volume), + rsi_2=VALUES(rsi_2), rsi_3=VALUES(rsi_3), rsi_5=VALUES(rsi_5), + is_confirmed=VALUES(is_confirmed), updated_at=VALUES(updated_at) + """, + rows[0], # 단건 execute (pymysql executemany 는 첫 번째 인수만 받음) + ) if len(rows) == 1 else self._executemany_batch(rows) + logger.debug("💾 [배치저장] %d봉 → DB", len(rows)) + except Exception as e: + logger.debug("_flush_batch 실패 (무시): %s", e) + + def _executemany_batch(self, rows: list) -> None: + """여러 봉을 executemany 로 한 번에 INSERT.""" + sql = """ + INSERT INTO ws_candles + (code, timeframe, candle_time, `open`, high, low, close, + volume, rsi_2, rsi_3, rsi_5, is_confirmed, source, updated_at) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + `open`=VALUES(`open`), high=VALUES(high), low=VALUES(low), + close=VALUES(close), volume=VALUES(volume), + rsi_2=VALUES(rsi_2), rsi_3=VALUES(rsi_3), rsi_5=VALUES(rsi_5), + is_confirmed=VALUES(is_confirmed), updated_at=VALUES(updated_at) + """ + # pymysql executemany: cursor.executemany(sql, list_of_tuples) + import pymysql + with self.db.conn._lock: + self.db.conn._ensure_connected() + cur = self.db.conn._conn.cursor() + cur.executemany(sql, rows) + + def stop(self) -> None: + """기록원 스레드를 안전하게 종료합니다.""" + if self._db_writer_thread and self._db_writer_thread.is_alive(): + self._write_queue.put(None) # 종료 신호 + self._db_writer_thread.join(timeout=5) + + # ------------------------------------------------------------------ + # 시각 유틸 + # ------------------------------------------------------------------ + + @staticmethod + def _candle_key(tick_time_str: str, timeframe: int) -> str: + """ + 틱 시각(HHMMSS)과 timeframe(분)으로 봉 시작 시각 키를 반환. + 날짜는 오늘 날짜를 사용 (장 중에만 동작하므로 안전). + 반환 형식: YYYYMMDDHHMM (분 단위, 초 버림) + + 예) tick_time="091523", timeframe=3 → "202603020915" (09:15봉) + """ + import datetime as _dt + try: + hh = int(tick_time_str[0:2]) if len(tick_time_str) >= 6 else 0 + mm = int(tick_time_str[2:4]) if len(tick_time_str) >= 6 else 0 + # timeframe 단위로 내림: 3분봉이면 09:17 → 09:15 + floored_mm = (mm // timeframe) * timeframe + today = _dt.date.today().strftime("%Y%m%d") + return f"{today}{hh:02d}{floored_mm:02d}" + except Exception: + import datetime as _dt + return _dt.datetime.now().strftime("%Y%m%d%H%M") + + # ------------------------------------------------------------------ + # RSI 계산 (확정 봉 close 리스트 기반) + # ------------------------------------------------------------------ + + @staticmethod + def _calc_rsi(closes: list, period: int) -> Optional[float]: + """ + close 가격 리스트에서 RSI(period) 계산. + - Wilder's smoothed moving average (단순 rolling mean 사용) + - 데이터 부족 시 None 반환 + """ + if len(closes) < period + 1: + return None + recent = closes[-(period + 1):] + gains, losses = [], [] + for i in range(1, len(recent)): + delta = recent[i] - recent[i - 1] + gains.append(max(delta, 0)) + losses.append(max(-delta, 0)) + avg_gain = sum(gains) / period if period > 0 else 0 + avg_loss = sum(losses) / period if period > 0 else 0 + if avg_loss == 0: + return 100.0 + rs = avg_gain / avg_loss + return round(100 - (100 / (1 + rs)), 2) + + def _compute_rsi_set(self, closes: list) -> tuple: + """RSI 2/3/5 를 한 번에 계산해서 (rsi2, rsi3, rsi5) 반환.""" + return ( + self._calc_rsi(closes, 2), + self._calc_rsi(closes, 3), + self._calc_rsi(closes, 5), + ) + + # ------------------------------------------------------------------ + # 핵심: 틱 수신 처리 + # ------------------------------------------------------------------ + + def on_tick(self, code: str, price: float, volume: int, tick_time: str) -> None: + """ + KISWebSocketPriceCache._parse_realtime_msg 에서 틱마다 호출됨. + 모든 timeframe 에 대해 봉 갱신/확정 처리. + """ + if price <= 0: + return + with self._lock: + for tf in self.timeframes: + self._process_tick(code, price, volume, tick_time, tf) + + def _process_tick(self, code: str, price: float, volume: int, + tick_time: str, tf: int) -> None: + """ + 단일 timeframe 에 대한 틱 처리 (lock 내부에서 호출). + + [트랙 1] RAM 갱신만 수행, DB 호출 없음 → 블로킹 0ms + [트랙 2] 봉 확정 순간에만 Queue.put_nowait() → 기록원이 비동기 배치 저장 + """ + key = (code, tf) + new_ctime = self._candle_key(tick_time, tf) + + if key not in self._current: + # 첫 틱: 새 봉 시작 (RAM만) + self._current[key] = { + "candle_time": new_ctime, + "open": price, "high": price, "low": price, + "close": price, "volume": volume, + } + return + + cur = self._current[key] + + if new_ctime != cur["candle_time"]: + # ── 봉 확정 ──────────────────────────────────────────── + closes = self._closes.setdefault(key, []) + closes.append(cur["close"]) + if len(closes) > self.MAX_CLOSE_BUFFER: + closes.pop(0) + + rsi2, rsi3, rsi5 = self._compute_rsi_set(closes) + + confirmed_candle = { + "code": code, + "tf": tf, + "candle_time": cur["candle_time"], + "open": cur["open"], + "high": cur["high"], + "low": cur["low"], + "close": cur["close"], + "volume": cur["volume"], + "rsi_2": rsi2, + "rsi_3": rsi3, + "rsi_5": rsi5, + "is_confirmed": 1, + "source": "ws", + } + + # [트랙 1] 확정 봉을 RAM _confirmed 버퍼에 보관 (매수 루프 직접 참조용) + buf = self._confirmed.setdefault(key, []) + buf.append(confirmed_candle) + if len(buf) > self.MAX_CLOSE_BUFFER: + buf.pop(0) + + # [트랙 2] Queue에 던지고 즉시 반환 (논블로킹) → 기록원이 배치 저장 + try: + self._write_queue.put_nowait(confirmed_candle) + except queue.Full: + logger.warning("⚠️ CandleAggregator 쓰기 Queue 가득참 — 봉 1개 DROP (코드: %s)", code) + + logger.debug( + "🕯 [봉확정] %s %dM %s C=%.0f RSI3=%s", + code, tf, cur["candle_time"], cur["close"], + f"{rsi3:.1f}" if rsi3 is not None else "N/A", + ) + + # ── 새 봉 시작 (RAM만) ────────────────────────────────── + self._current[key] = { + "candle_time": new_ctime, + "open": price, "high": price, "low": price, + "close": price, "volume": volume, + } + else: + # 같은 봉: OHLCV 갱신 (RAM만, DB 쓰기 없음) + cur["high"] = max(cur["high"], price) + cur["low"] = min(cur["low"], price) + cur["close"] = price + cur["volume"] = volume # 누적 거래량(ACML_VOL) 그대로 덮어씀 + + # ------------------------------------------------------------------ + # 재접속 갭 보정: REST get_minute_chart 로 빈 봉 채우기 + # ------------------------------------------------------------------ + + def fill_gap_from_rest(self, code: str, tf: int, rest_df) -> int: + """ + WS 재접속 후 빠진 봉 구간을 REST 분봉 데이터로 채움. + + [트랙 1] close 가격을 _closes / _confirmed 에 넣어 RSI 웜업 + [트랙 2] 봉 dict를 Queue 에 넣어 기록원이 배치로 DB 저장 (source='rest') + + Args: + code : 종목코드 + tf : timeframe (분) + rest_df : get_minute_chart 반환 DataFrame (오래된→최신 순 정렬 필요) + 컬럼: time, open, high, low, close, volume + + Returns: + 채워진 봉 수 + """ + if rest_df is None or rest_df.empty: + return 0 + + with self._lock: + key = (code, tf) + closes = self._closes.setdefault(key, []) + conf_buf = self._confirmed.setdefault(key, []) + inserted = 0 + + for _, row in rest_df.iterrows(): + ctime = str(row.get("time", ""))[:12] # YYYYMMDDHHMM 12자리 + if not ctime or len(ctime) < 12: + continue + close = float(row.get("close", 0)) + if close <= 0: + continue + + # [트랙 1] RAM 웜업 + closes.append(close) + if len(closes) > self.MAX_CLOSE_BUFFER: + closes.pop(0) + + rsi2, rsi3, rsi5 = self._compute_rsi_set(closes) + + candle = { + "code": code, + "tf": tf, + "candle_time": ctime, + "open": float(row.get("open", close)), + "high": float(row.get("high", close)), + "low": float(row.get("low", close)), + "close": close, + "volume": int(row.get("volume", 0)), + "rsi_2": rsi2, + "rsi_3": rsi3, + "rsi_5": rsi5, + "is_confirmed": 1, + "source": "rest", + } + conf_buf.append(candle) + if len(conf_buf) > self.MAX_CLOSE_BUFFER: + conf_buf.pop(0) + + # [트랙 2] Queue에 넣어 기록원이 배치로 DB 저장 + try: + self._write_queue.put_nowait(candle) + except queue.Full: + pass + inserted += 1 + + if inserted: + logger.info("🔧 [갭보정] %s %dM → REST %d봉 RAM 적재 + DB 큐 등록", code, tf, inserted) + return inserted + + # ------------------------------------------------------------------ + # [트랙 1] RAM 버퍼 조회 — 매수/매도 루프에서 직접 호출 (DB 조회 없음) + # ------------------------------------------------------------------ + + def get_latest_confirmed(self, code: str, tf: int) -> Optional[dict]: + """ + 가장 최근 확정된 봉(완성된 마지막 봉)을 반환. + None이면 아직 봉이 확정되지 않음 (장 초반 등). + """ + with self._lock: + buf = self._confirmed.get((code, tf)) + return buf[-1] if buf else None + + def get_prev_confirmed(self, code: str, tf: int) -> Optional[dict]: + """직전 확정봉 (최신에서 2번째). 패턴 확인용 (현재봉 - 1).""" + with self._lock: + buf = self._confirmed.get((code, tf)) + return buf[-2] if buf and len(buf) >= 2 else None + + def get_candles(self, code: str, tf: int, n: int = 10) -> list: + """최근 n개 확정 봉 리스트 반환 (오래된→최신 순).""" + with self._lock: + buf = self._confirmed.get((code, tf), []) + return list(buf[-n:]) + + def get_confirmed_count(self, code: str, tf: int) -> int: + """확정된 봉 수 (RSI 안정화 여부 확인용).""" + with self._lock: + return len(self._confirmed.get((code, tf), [])) + + def get_current_candle(self, code: str, tf: int) -> Optional[dict]: + """ + 현재 진행 중인 봉(미확정, is_confirmed=0) 반환. + RSI는 포함되지 않음 (확정 봉 기준으로만 계산). + """ + with self._lock: + return dict(self._current.get((code, tf), {})) or None + + def get_rsi(self, code: str, tf: int, period: int = 3) -> Optional[float]: + """ + 최신 확정 봉의 RSI(period) 값 반환. + period: 2, 3, 5 중 하나 (스캘핑 단타용 초단기 RSI) + """ + candle = self.get_latest_confirmed(code, tf) + if candle is None: + return None + return candle.get(f"rsi_{period}") + + def remove_code(self, code: str) -> None: + """ + 유니버스에서 빠진 종목의 RAM 버퍼를 정리합니다. + _sync_subscriptions()에서 구독 해제 시 같이 호출. + 등록된 모든 timeframe 의 _confirmed / _closes / _current 를 삭제. + """ + with self._lock: + for tf in list(self.timeframes): + key = (code, tf) + self._confirmed.pop(key, None) + self._closes.pop(key, None) + self._current.pop(key, None) + logger.debug("🗑️ CandleAggregator RAM 정리: %s", code) + + +# ====================================================================== +# 키움 REST API 공통 유틸 — 봇 시작 시 WS 갭보정용 +# KIS get_minute_chart() 는 당일봉만 제공하지만, +# 키움 ka10080 은 1회 호출에 최대 900봉(≈6개월) + 페이지네이션 지원. +# ====================================================================== + +# ────────────────────────────────────────────────────────────────────── +# 키움 토큰 매니저 싱글톤 풀 +# +# kiwoom_rest_api/auth/token.py 의 TokenManager 와 동일한 설계: +# - _is_access_token_valid(): expires_dt 기반 정확한 만료 판별 (30s 버퍼) +# - _request_new_token(): 만료 시에만 발급 (au10001 rate limit 방지) +# +# 차이점: DB에서 읽은 app_key/secret 직접 주입 (환경변수 불필요) +# 싱글톤 풀: 캐시 키(domain:appkey앞8자) → 인스턴스 재사용 +# 종목×timeframe마다 새 인스턴스를 만들지 않음 +# ────────────────────────────────────────────────────────────────────── +import threading as _threading +from datetime import datetime as _datetime, timedelta as _timedelta + +_kiwoom_token_lock = _threading.Lock() +_kiwoom_managers: Dict[str, "KiwoomTokenManager"] = {} # 싱글톤 풀 + + +class KiwoomTokenManager: + """ + kiwoom_rest_api/auth/token.py TokenManager 와 동일한 로직 — DB 키 직접 주입 버전. + + - get_token(): 유효한 토큰이면 바로 반환, 만료 시에만 재발급 (au10001 방지) + - expires_dt 를 정확히 파싱 (하드코딩 23h 아님) + - 30초 버퍼: 경계 케이스 방지 (TokenManager 와 동일) + - 스레드 안전: 인스턴스당 Lock 보유 + """ + + def __init__(self, app_key: str, app_secret: str, is_mock: bool = False): + self._app_key = app_key + self._app_secret = app_secret + self._is_mock = is_mock + self._domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com" + self._mode_str = "모의" if is_mock else "실전" + self._token: Optional[str] = None + self._expiry: Optional[_datetime] = None + self._lock = _threading.Lock() + + def _is_valid(self) -> bool: + """만료 30초 전까지 유효 (TokenManager._is_access_token_valid 와 동일)""" + if not self._token or not self._expiry: + return False + return _datetime.now() < self._expiry - _timedelta(seconds=30) + + def _request_new_token(self) -> None: + """토큰 신규 발급 — 만료 시에만 호출됨""" + resp = requests.post( + f"https://{self._domain}/oauth2/token", + json={ + "grant_type": "client_credentials", + "appkey": self._app_key, + "secretkey": self._app_secret, + }, + timeout=10, + ) + data = resp.json() + token = (data.get("token") or data.get("access_token") or "").strip() + if not token: + raise RuntimeError(f"키움 토큰 발급 실패 [{self._mode_str}]: {data}") + + # expires_dt 정확히 파싱 (TokenManager._update_token_info 와 동일 로직) + exp_s = data.get("expires_dt", "") + try: + self._expiry = _datetime.strptime(str(exp_s), "%Y%m%d%H%M%S") + except Exception: + # expires_in 도 없으면 24h 기본 (보수적 fallback) + exp_in = data.get("expires_in", 86400) + self._expiry = _datetime.now() + _timedelta(seconds=int(exp_in)) + + self._token = token + logger.info( + "✅ 키움 토큰 발급 완료 [%s] (앞8자: %s…, 만료: %s)", + self._mode_str, token[:8], self._expiry.strftime("%Y-%m-%d %H:%M:%S"), + ) + + def get_token(self) -> Optional[str]: + """ + 유효한 토큰 반환. 만료 시에만 재발급. + kiwoom_rest_api TokenManager.get_token() 호환 인터페이스. + """ + with self._lock: + if not self._is_valid(): + try: + self._request_new_token() + except Exception as e: + logger.warning("⚠️ 키움 토큰 발급 예외 [%s]: %s", self._mode_str, e) + return None + return self._token + + +def _get_kiwoom_token_cached( + kiwoom_key: str, + kiwoom_secret: str, + is_mock: bool, +) -> Optional[str]: + """ + KiwoomTokenManager 싱글톤 풀에서 인스턴스를 꺼내 토큰 반환. + 동일 키 조합은 같은 인스턴스를 재사용 → au10001 rate limit 방지. + """ + domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com" + cache_key = f"{domain}:{kiwoom_key[:8]}" + + with _kiwoom_token_lock: + if cache_key not in _kiwoom_managers: + _kiwoom_managers[cache_key] = KiwoomTokenManager( + kiwoom_key, kiwoom_secret, is_mock=is_mock + ) + mgr = _kiwoom_managers[cache_key] + + return mgr.get_token() + + +def _get_kiwoom_creds(db) -> tuple: + """ + DB env_config 최신 행에서 키움 앱키/시크릿 반환. + KIS_MOCK 설정에 따라 MOCK / REAL 키를 자동 선택. + + Returns: + (app_key, app_secret, is_mock) — 키 없으면 (None, None, False) + """ + try: + row = db.conn.execute( + "SELECT * FROM env_config ORDER BY id DESC LIMIT 1" + ).fetchone() + if not row: + return None, None, False + r = dict(row) + is_mock = str(r.get("KIS_MOCK", "true")).lower() in ("true", "1", "yes") + if is_mock: + key = str(r.get("KIWOOM_APP_KEY_MOCK", "") or "").strip() + secret = str(r.get("KIWOOM_APP_SECRET_MOCK", "") or "").strip() + else: + key = str(r.get("KIWOOM_APP_KEY_REAL", "") or "").strip() + secret = str(r.get("KIWOOM_APP_SECRET_REAL", "") or "").strip() + # 레거시 필드 폴백 (KIWOOM_APP_KEY) + if not key or not secret: + key = str(r.get("KIWOOM_APP_KEY", "") or "").strip() + secret = str(r.get("KIWOOM_APP_SECRET", "") or "").strip() + if not key or not secret: + return None, None, is_mock + return key, secret, is_mock + except Exception as e: + logger.debug("키움 크레덴셜 조회 실패: %s", e) + return None, None, False + + +def get_kiwoom_candles_df( + code: str, + tf_min: int, + kiwoom_key: str, + kiwoom_secret: str, + is_mock: bool = False, + n: int = 120, +) -> "object": # pd.DataFrame + """ + 키움 REST API (ka10080 — 주식분봉차트조회) 로 분봉 조회. + fill_gap_from_rest() 호환 DataFrame 반환. + + Args: + code : 종목코드 (6자리) + tf_min : 봉 단위 분 (1 / 3 / 5 / 10 / 15 / 30 / 45 / 60) + kiwoom_key : 키움 앱키 + kiwoom_secret: 키움 앱시크릿 + is_mock : True = 모의투자 도메인 사용 + n : 최대 수집 봉 수 (기본 120, 60분봉 기준 약 17 영업일) + + Returns: + pd.DataFrame — 컬럼: time(YYYYMMDDHHMM), open, high, low, close, volume + 오래된→최신 순 (fill_gap_from_rest 기대 순서) + 빈 DataFrame (오류 시) + """ + try: + import pandas as pd + except ImportError: + logger.error("pandas 미설치 → 키움 갭보정 불가") + return None # type: ignore[return-value] + + domain = "mockapi.kiwoom.com" if is_mock else "api.kiwoom.com" + + # ── 1. 키움 OAuth 토큰 (캐시 우선 — 23시간 재사용, au10001 rate limit 방지) ── + token = _get_kiwoom_token_cached(kiwoom_key, kiwoom_secret, is_mock) + if not token: + return pd.DataFrame() + + # ── 2. ka10080 분봉 차트 조회 (페이지네이션) ────────────────────── + # 키움은 최신→과거 순으로 반환, 한 페이지에 최대 900봉 + base_url = f"https://{domain}/api/dostk/chart" + headers = { + "content-type": "application/json;charset=UTF-8", + "appkey": kiwoom_key, + "appsecret": kiwoom_secret, + "authorization": f"Bearer {token}", + "api-id": "ka10080", + "cont-yn": "N", + "next-key": "", + } + body = { + "stk_cd": code, + "tic_scope": str(tf_min), # "1" / "3" / "15" / "60" etc. + "upd_stkpc_tp": "1", # 수정주가 반영 + } + + rows: list = [] + while len(rows) < n: + try: + resp = requests.post(base_url, json=body, headers=headers, timeout=15) + data = resp.json() + except Exception as e: + logger.warning("⚠️ 키움 ka10080 조회 실패 (%s %dM): %s", code, tf_min, e) + break + + records = data.get("stk_min_pole_chart_qry") or [] + if not records: + break + + for rec in records: + raw_dt = str(rec.get("cntr_tm", "")) + if len(raw_dt) < 12: + continue + try: + rows.append({ + # cntr_tm = YYYYMMDDHHMMSS → 12자리로 잘라 YYYYMMDDHHMM + "time": raw_dt[:12], + "open": abs(float(rec.get("open_pric", 0) or 0)), + "high": abs(float(rec.get("high_pric", 0) or 0)), + "low": abs(float(rec.get("low_pric", 0) or 0)), + "close": abs(float(rec.get("cur_prc", 0) or 0)), + "volume": abs(int(float(rec.get("trde_qty", 0) or 0))), + }) + except (ValueError, TypeError): + continue + if len(rows) >= n: + break + + # 연속 조회 여부 확인 (응답 헤더) + 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 or len(rows) >= n: + break + headers["cont-yn"] = cont_yn + headers["next-key"] = next_key + time.sleep(0.35) # 키움 API 레이트리밋 + + if not rows: + logger.debug("키움 ka10080 반환 행 없음 (%s %dM)", code, tf_min) + return pd.DataFrame() + + # 키움은 최신→과거 순 → fill_gap_from_rest 는 오래된→최신 순 필요 → 역순 + df = pd.DataFrame(rows[:n][::-1]) + logger.info("✅ 키움 %dM 갭보정 데이터: %s %d봉", tf_min, code, len(df)) + return df + + diff --git a/kiwoom_rest_api/__init__.py b/kiwoom_rest_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiwoom_rest_api/api.py b/kiwoom_rest_api/api.py new file mode 100644 index 0000000..e69de29 diff --git a/kiwoom_rest_api/api_async.py b/kiwoom_rest_api/api_async.py new file mode 100644 index 0000000..e69de29 diff --git a/kiwoom_rest_api/auth/__init__.py b/kiwoom_rest_api/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiwoom_rest_api/auth/token.py b/kiwoom_rest_api/auth/token.py new file mode 100644 index 0000000..995ebbe --- /dev/null +++ b/kiwoom_rest_api/auth/token.py @@ -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()) + diff --git a/kiwoom_rest_api/cli/__init__.py b/kiwoom_rest_api/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiwoom_rest_api/cli/main.py b/kiwoom_rest_api/cli/main.py new file mode 100644 index 0000000..d6f05ae --- /dev/null +++ b/kiwoom_rest_api/cli/main.py @@ -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() diff --git a/kiwoom_rest_api/config.py b/kiwoom_rest_api/config.py new file mode 100644 index 0000000..5d8549f --- /dev/null +++ b/kiwoom_rest_api/config.py @@ -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 diff --git a/kiwoom_rest_api/core/__init__.py b/kiwoom_rest_api/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiwoom_rest_api/core/async_client.py b/kiwoom_rest_api/core/async_client.py new file mode 100644 index 0000000..29386d0 --- /dev/null +++ b/kiwoom_rest_api/core/async_client.py @@ -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) diff --git a/kiwoom_rest_api/core/base.py b/kiwoom_rest_api/core/base.py new file mode 100644 index 0000000..6e59340 --- /dev/null +++ b/kiwoom_rest_api/core/base.py @@ -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)}) diff --git a/kiwoom_rest_api/core/base_api.py b/kiwoom_rest_api/core/base_api.py new file mode 100644 index 0000000..d72534d --- /dev/null +++ b/kiwoom_rest_api/core/base_api.py @@ -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) diff --git a/kiwoom_rest_api/core/sync_client.py b/kiwoom_rest_api/core/sync_client.py new file mode 100644 index 0000000..4243190 --- /dev/null +++ b/kiwoom_rest_api/core/sync_client.py @@ -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) diff --git a/kiwoom_rest_api/koreanstock/__init__.py b/kiwoom_rest_api/koreanstock/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/kiwoom_rest_api/koreanstock/account.py b/kiwoom_rest_api/koreanstock/account.py new file mode 100644 index 0000000..423b7ae --- /dev/null +++ b/kiwoom_rest_api/koreanstock/account.py @@ -0,0 +1,2050 @@ +from kiwoom_rest_api.core.base_api import KiwoomBaseAPI +from typing import Union, Dict, Any, Awaitable + +class Account(KiwoomBaseAPI): + """한국 주식 계좌 관련 API를 제공하는 클래스""" + + def __init__( + self, + base_url: str = None, + token_manager=None, + use_async: bool = False, + resource_url: str = "/api/dostk/acnt" + ): + """ + Account 클래스 초기화 + + 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 realized_profit_by_date_stock_request_ka10072( + self, + stock_code: str, + start_date: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 일자별종목별실현손익요청 (ka10072) + + Args: + stock_code (str): 종목코드 (6자리) + start_date (str): 시작일자 (YYYYMMDD) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 일자별종목별실현손익 데이터 + { + "dt_stk_div_rlzt_pl": [ + { + "stk_nm": str, # 종목명 + "cntr_qty": str, # 체결량 + "buy_uv": str, # 매입단가 + "cntr_pric": str, # 체결가 + "tdy_sel_pl": str, # 당일매도손익 + "pl_rt": str, # 손익율 + "stk_cd": str, # 종목코드 + "tdy_trde_cmsn": str, # 당일매매수수료 + "tdy_trde_tax": str, # 당일매매세금 + "wthd_alowa": str, # 인출가능금액 + "loan_dt": str, # 대출일 + "crd_tp": str, # 신용구분 + "stk_cd_1": str, # 종목코드1 + "tdy_sel_pl_1": str, # 당일매도손익1 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.realized_profit_by_date_stock_request_ka10072( + ... stock_code="005930", + ... start_date="20241128" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10072", + } + data = { + "stk_cd": stock_code, + "strt_dt": start_date, + } + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def realized_profit_by_period_stock_request_ka10073( + self, + stock_code: str, + start_date: str, + end_date: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 일자별종목별실현손익요청_기간 (ka10073) + + Args: + stock_code (str): 종목코드 (6자리) + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 일자별종목별실현손익 데이터 + { + "dt_stk_rlzt_pl": [ + { + "dt": str, # 일자 + "tdy_htssel_cmsn": str, # 당일hts매도수수료 + "stk_nm": str, # 종목명 + "cntr_qty": str, # 체결량 + "buy_uv": str, # 매입단가 + "cntr_pric": str, # 체결가 + "tdy_sel_pl": str, # 당일매도손익 + "pl_rt": str, # 손익율 + "stk_cd": str, # 종목코드 + "tdy_trde_cmsn": str, # 당일매매수수료 + "tdy_trde_tax": str, # 당일매매세금 + "wthd_alowa": str, # 인출가능금액 + "loan_dt": str, # 대출일 + "crd_tp": str, # 신용구분 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.realized_profit_by_period_stock_request_ka10073( + ... stock_code="005930", + ... start_date="20241128", + ... end_date="20241128" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10073", + } + data = { + "stk_cd": stock_code, + "strt_dt": start_date, + "end_dt": end_date, + } + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def daily_realized_profit_request_ka10074( + self, + start_date: str, + end_date: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 일자별실현손익요청 (ka10074) + + Args: + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 일자별실현손익 데이터 + { + "tot_buy_amt": str, # 총매수금액 + "tot_sell_amt": str, # 총매도금액 + "rlzt_pl": str, # 실현손익 + "trde_cmsn": str, # 매매수수료 + "trde_tax": str, # 매매세금 + "dt_rlzt_pl": [ + { + "dt": str, # 일자 + "buy_amt": str, # 매수금액 + "sell_amt": str, # 매도금액 + "tdy_sel_pl": str, # 당일매도손익 + "tdy_trde_cmsn": str, # 당일매매수수료 + "tdy_trde_tax": str, # 당일매매세금 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.daily_realized_profit_request_ka10074( + ... start_date="20241128", + ... end_date="20241128" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10074", + } + data = { + "strt_dt": start_date, + "end_dt": end_date, + } + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def unfilled_orders_request_ka10075( + self, + all_stk_tp: str, + trde_tp: str, + stex_tp: str, + stock_code: str = None, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 미체결요청 (ka10075) + + Args: + all_stk_tp (str): 전체종목구분 (0:전체, 1:종목) + trde_tp (str): 매매구분 (0:전체, 1:매도, 2:매수) + stex_tp (str): 거래소구분 (0:통합, 1:KRX, 2:NXT) + stock_code (str, optional): 종목코드 (6자리). Required when all_stk_tp is "1". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 미체결 데이터 + { + "oso": [ + { + "acnt_no": str, # 계좌번호 + "ord_no": str, # 주문번호 + "mang_empno": str, # 관리사번 + "stk_cd": str, # 종목코드 + "tsk_tp": str, # 업무구분 + "ord_stt": str, # 주문상태 + "stk_nm": str, # 종목명 + "ord_qty": str, # 주문수량 + "ord_pric": str, # 주문가격 + "oso_qty": str, # 미체결수량 + "cntr_tot_amt": str, # 체결누계금액 + "orig_ord_no": str, # 원주문번호 + "io_tp_nm": str, # 주문구분 + "trde_tp": str, # 매매구분 + "tm": str, # 시간 + "cntr_no": str, # 체결번호 + "cntr_pric": str, # 체결가 + "cntr_qty": str, # 체결량 + "cur_prc": str, # 현재가 + "sel_bid": str, # 매도호가 + "buy_bid": str, # 매수호가 + "unit_cntr_pric": str, # 단위체결가 + "unit_cntr_qty": str, # 단위체결량 + "tdy_trde_cmsn": str, # 당일매매수수료 + "tdy_trde_tax": str, # 당일매매세금 + "ind_invsr": str, # 개인투자자 + "stex_tp": str, # 거래소구분 + "stex_tp_txt": str, # 거래소구분텍스트 + "sor_yn": str, # SOR 여부값 + "stop_pric": str, # 스톱가 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.unfilled_orders_request_ka10075( + ... all_stk_tp="1", + ... trde_tp="0", + ... stex_tp="0", + ... stock_code="005930" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10075", + } + data = { + "all_stk_tp": all_stk_tp, + "trde_tp": trde_tp, + "stex_tp": stex_tp, + } + if stock_code: + data["stk_cd"] = stock_code + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def filled_orders_request_ka10076( + self, + qry_tp: str, + sell_tp: str, + stex_tp: str, + stock_code: str = None, + order_no: str = None, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 체결요청 (ka10076) + + Args: + qry_tp (str): 조회구분 (0:전체, 1:종목) + sell_tp (str): 매도수구분 (0:전체, 1:매도, 2:매수) + stex_tp (str): 거래소구분 (0:통합, 1:KRX, 2:NXT) + stock_code (str, optional): 종목코드 (6자리). Required when qry_tp is "1". + order_no (str, optional): 주문번호. 검색 기준 값으로 입력한 주문번호 보다 과거에 체결된 내역이 조회됩니다. + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 체결 데이터 + { + "cntr": [ + { + "ord_no": str, # 주문번호 + "stk_nm": str, # 종목명 + "io_tp_nm": str, # 주문구분 + "ord_pric": str, # 주문가격 + "ord_qty": str, # 주문수량 + "cntr_pric": str, # 체결가 + "cntr_qty": str, # 체결량 + "oso_qty": str, # 미체결수량 + "tdy_trde_cmsn": str, # 당일매매수수료 + "tdy_trde_tax": str, # 당일매매세금 + "ord_stt": str, # 주문상태 + "trde_tp": str, # 매매구분 + "orig_ord_no": str, # 원주문번호 + "ord_tm": str, # 주문시간 + "stk_cd": str, # 종목코드 + "stex_tp": str, # 거래소구분 + "stex_tp_txt": str, # 거래소구분텍스트 + "sor_yn": str, # SOR 여부값 + "stop_pric": str, # 스톱가 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.filled_orders_request_ka10076( + ... qry_tp="1", + ... sell_tp="0", + ... stex_tp="0", + ... stock_code="005930" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10076", + } + data = { + "qry_tp": qry_tp, + "sell_tp": sell_tp, + "stex_tp": stex_tp, + } + if stock_code: + data["stk_cd"] = stock_code + if order_no: + data["ord_no"] = order_no + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def today_realized_profit_detail_request_ka10077( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 당일실현손익상세요청 (ka10077) + + Args: + stock_code (str): 종목코드 (6자리) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 당일실현손익 상세 데이터 + { + "tdy_rlzt_pl": str, # 당일실현손익 + "tdy_rlzt_pl_dtl": [ + { + "stk_nm": str, # 종목명 + "cntr_qty": str, # 체결량 + "buy_uv": str, # 매입단가 + "cntr_pric": str, # 체결가 + "tdy_sel_pl": str, # 당일매도손익 + "pl_rt": str, # 손익율 + "tdy_trde_cmsn": str, # 당일매매수수료 + "tdy_trde_tax": str, # 당일매매세금 + "stk_cd": str, # 종목코드 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.today_realized_profit_detail_request_ka10077( + ... stock_code="005930" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10077", + } + data = { + "stk_cd": stock_code, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def account_return_rate_request_ka10085( + self, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 계좌수익률요청 (ka10085) + + Args: + stex_tp (str): 거래소구분 (0:통합, 1:KRX, 2:NXT) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 계좌수익률 데이터 + { + "acnt_prft_rt": [ + { + "dt": str, # 일자 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pur_pric": str, # 매입가 + "pur_amt": str, # 매입금액 + "rmnd_qty": str, # 보유수량 + "tdy_sel_pl": str, # 당일매도손익 + "tdy_trde_cmsn": str, # 당일매매수수료 + "tdy_trde_tax": str, # 당일매매세금 + "crd_tp": str, # 신용구분 + "loan_dt": str, # 대출일 + "setl_remn": str, # 결제잔고 + "clrn_alow_qty": str, # 청산가능수량 + "crd_amt": str, # 신용금액 + "crd_int": str, # 신용이자 + "expr_dt": str, # 만기일 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.account_return_rate_request_ka10085( + ... stex_tp="0" # 통합 거래소 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10085", + } + data = { + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def unfilled_split_order_detail_request_ka10088( + self, + order_no: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 미체결 분할주문 상세 요청 (ka10088) + + Args: + order_no (str): 주문번호 (20자리) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 미체결 분할주문 상세 데이터 + { + "osop": [ + { + "stk_cd": str, # 종목코드 + "acnt_no": str, # 계좌번호 + "stk_nm": str, # 종목명 + "ord_no": str, # 주문번호 + "ord_qty": str, # 주문수량 + "ord_pric": str, # 주문가격 + "osop_qty": str, # 미체결수량 + "io_tp_nm": str, # 주문구분 + "trde_tp": str, # 매매구분 + "sell_tp": str, # 매도/수 구분 + "cntr_qty": str, # 체결량 + "ord_stt": str, # 주문상태 + "cur_prc": str, # 현재가 + "stex_tp": str, # 거래소구분 (0:통합, 1:KRX, 2:NXT) + "stex_tp_txt": str, # 거래소구분텍스트 (통합,KRX,NXT) + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.unfilled_split_order_detail_request_ka10088( + ... order_no="8" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10088", + } + data = { + "ord_no": order_no, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def today_trading_journal_request_ka10170( + self, + ottks_tp: str, + ch_crd_tp: str, + base_dt: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 당일매매일지 요청 (ka10170) + + Args: + ottks_tp (str): 단주구분 (1:당일매수에 대한 당일매도, 2:당일매도 전체) + ch_crd_tp (str): 현금신용구분 (0:전체, 1:현금매매만, 2:신용매매만) + base_dt (str, optional): 기준일자 (YYYYMMDD). 공백입력시 금일데이터, 최근 2개월까지 제공. Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 당일매매일지 데이터 + { + "tot_sell_amt": str, # 총매도금액 + "tot_buy_amt": str, # 총매수금액 + "tot_cmsn_tax": str, # 총수수료_세금 + "tot_exct_amt": str, # 총정산금액 + "tot_pl_amt": str, # 총손익금액 + "tot_prft_rt": str, # 총수익률 + "tdy_trde_diary": [ # 당일매매일지 + { + "stk_nm": str, # 종목명 + "buy_avg_pric": str, # 매수평균가 + "buy_qty": str, # 매수수량 + "sel_avg_pric": str, # 매도평균가 + "sell_qty": str, # 매도수량 + "cmsn_alm_tax": str, # 수수료_제세금 + "pl_amt": str, # 손익금액 + "sell_amt": str, # 매도금액 + "buy_amt": str, # 매수금액 + "prft_rt": str, # 수익률 + "stk_cd": str, # 종목코드 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.today_trading_journal_request_ka10170( + ... ottks_tp="1", + ... ch_crd_tp="0", + ... base_dt="20241120" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10170", + } + data = { + "ottks_tp": ottks_tp, + "ch_crd_tp": ch_crd_tp, + } + if base_dt: + data["base_dt"] = base_dt + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def deposit_detail_status_request_kt00001( + self, + qry_tp: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 예수금상세현황 요청 (kt00001) + + Args: + qry_tp (str): 조회구분 (3:추정조회, 2:일반조회) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 예수금상세현황 데이터 + { + "entr": str, # 예수금 + "profa_ch": str, # 주식증거금현금 + "bncr_profa_ch": str, # 수익증권증거금현금 + "nxdy_bncr_sell_exct": str, # 익일수익증권매도정산대금 + "fc_stk_krw_repl_set_amt": str, # 해외주식원화대용설정금 + "crd_grnta_ch": str, # 신용보증금현금 + "crd_grnt_ch": str, # 신용담보금현금 + "add_grnt_ch": str, # 추가담보금현금 + "etc_profa": str, # 기타증거금 + "uncl_stk_amt": str, # 미수확보금 + "shrts_prica": str, # 공매도대금 + "crd_set_grnta": str, # 신용설정평가금 + "chck_ina_amt": str, # 수표입금액 + "etc_chck_ina_amt": str, # 기타수표입금액 + "crd_grnt_ruse": str, # 신용담보재사용 + "knx_asset_evltv": str, # 코넥스기본예탁금 + "elwdpst_evlta": str, # ELW예탁평가금 + "crd_ls_rght_frcs_amt": str, # 신용대주권리예정금액 + "lvlh_join_amt": str, # 생계형가입금액 + "lvlh_trns_alowa": str, # 생계형입금가능금액 + "repl_amt": str, # 대용금평가금액(합계) + "remn_repl_evlta": str, # 잔고대용평가금액 + "trst_remn_repl_evlta": str, # 위탁대용잔고평가금액 + "bncr_remn_repl_evlta": str, # 수익증권대용평가금액 + "profa_repl": str, # 위탁증거금대용 + "crd_grnta_repl": str, # 신용보증금대용 + "crd_grnt_repl": str, # 신용담보금대용 + "add_grnt_repl": str, # 추가담보금대용 + "rght_repl_amt": str, # 권리대용금 + "pymn_alow_amt": str, # 출금가능금액 + "wrap_pymn_alow_amt": str, # 랩출금가능금액 + "ord_alow_amt": str, # 주문가능금액 + "bncr_buy_alowa": str, # 수익증권매수가능금액 + "20stk_ord_alow_amt": str, # 20%종목주문가능금액 + "30stk_ord_alow_amt": str, # 30%종목주문가능금액 + "40stk_ord_alow_amt": str, # 40%종목주문가능금액 + "100stk_ord_alow_amt": str, # 100%종목주문가능금액 + "ch_uncla": str, # 현금미수금 + "ch_uncla_dlfe": str, # 현금미수연체료 + "ch_uncla_tot": str, # 현금미수금합계 + "crd_int_npay": str, # 신용이자미납 + "int_npay_amt_dlfe": str, # 신용이자미납연체료 + "int_npay_amt_tot": str, # 신용이자미납합계 + "etc_loana": str, # 기타대여금 + "etc_loana_dlfe": str, # 기타대여금연체료 + "etc_loan_tot": str, # 기타대여금합계 + "nrpy_loan": str, # 미상환융자금 + "loan_sum": str, # 융자금합계 + "ls_sum": str, # 대주금합계 + "crd_grnt_rt": str, # 신용담보비율 + "mdstrm_usfe": str, # 중도이용료 + "min_ord_alow_yn": str, # 최소주문가능금액 + "loan_remn_evlt_amt": str, # 대출총평가금액 + "dpst_grntl_remn": str, # 예탁담보대출잔고 + "sell_grntl_remn": str, # 매도담보대출잔고 + "d1_entra": str, # d+1추정예수금 + "d1_slby_exct_amt": str, # d+1매도매수정산금 + "d1_buy_exct_amt": str, # d+1매수정산금 + "d1_out_rep_mor": str, # d+1미수변제소요금 + "d1_sel_exct_amt": str, # d+1매도정산금 + "d1_pymn_alow_amt": str, # d+1출금가능금액 + "d2_entra": str, # d+2추정예수금 + "d2_slby_exct_amt": str, # d+2매도매수정산금 + "d2_buy_exct_amt": str, # d+2매수정산금 + "d2_out_rep_mor": str, # d+2미수변제소요금 + "d2_sel_exct_amt": str, # d+2매도정산금 + "d2_pymn_alow_amt": str, # d+2출금가능금액 + "50stk_ord_alow_amt": str, # 50%종목주문가능금액 + "60stk_ord_alow_amt": str, # 60%종목주문가능금액 + "stk_entr_prst": [ # 종목별예수금 + { + "crnc_cd": str, # 통화코드 + "fx_entr": str, # 외화예수금 + "fc_krw_repl_evlta": str, # 원화대용평가금 + "fc_trst_profa": str, # 해외주식증거금 + "pymn_alow_amt": str, # 출금가능금액 + "pymn_alow_amt_entr": str, # 출금가능금액(예수금) + "ord_alow_amt_entr": str, # 주문가능금액(예수금) + "fc_uncla": str, # 외화미수(합계) + "fc_ch_uncla": str, # 외화현금미수금 + "dly_amt": str, # 연체료 + "d1_fx_entr": str, # d+1외화예수금 + "d2_fx_entr": str, # d+2외화예수금 + "d3_fx_entr": str, # d+3외화예수금 + "d4_fx_entr": str, # d+4외화예수금 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.deposit_detail_status_request_kt00001( + ... qry_tp="3" # 추정조회 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00001", + } + data = { + "qry_tp": qry_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def daily_estimated_deposit_asset_status_request_kt00002( + self, + start_dt: str, + end_dt: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 일별추정예탁자산현황 요청 (kt00002) + + Args: + start_dt (str): 시작조회기간 (YYYYMMDD) + end_dt (str): 종료조회기간 (YYYYMMDD) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 일별추정예탁자산현황 데이터 + { + "daly_prsm_dpst_aset_amt_prst": [ # 일별추정예탁자산현황 + { + "dt": str, # 일자 + "entr": str, # 예수금 + "grnt_use_amt": str, # 담보대출금 + "crd_loan": str, # 신용융자금 + "ls_grnt": str, # 대주담보금 + "repl_amt": str, # 대용금 + "prsm_dpst_aset_amt": str, # 추정예탁자산 + "prsm_dpst_aset_amt_bncr_skip": str, # 추정예탁자산수익증권제외 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.daily_estimated_deposit_asset_status_request_kt00002( + ... start_dt="20241111", + ... end_dt="20241125" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00002", + } + data = { + "start_dt": start_dt, + "end_dt": end_dt, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def estimated_asset_inquiry_request_kt00003( + self, + qry_tp: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 추정자산조회요청 (kt00003) + + Args: + qry_tp (str): 상장폐지조회구분 (0:전체, 1:상장폐지종목제외) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 추정자산 데이터 + { + "prsm_dpst_aset_amt": str, # 추정예탁자산 + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.estimated_asset_inquiry_request_kt00003( + ... qry_tp="0" # 전체 조회 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00003", + } + data = { + "qry_tp": qry_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def account_evaluation_status_request_kt00004( + self, + qry_tp: str, + dmst_stex_tp: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 계좌평가현황요청 (kt00004) + + Args: + qry_tp (str): 상장폐지조회구분 (0:전체, 1:상장폐지종목제외) + dmst_stex_tp (str): 국내거래소구분 (KRX:한국거래소, NXT:넥스트트레이드) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 계좌평가현황 데이터 + { + "acnt_nm": str, # 계좌명 + "brch_nm": str, # 지점명 + "entr": str, # 예수금 + "d2_entra": str, # D+2추정예수금 + "tot_est_amt": str, # 유가잔고평가액 + "aset_evlt_amt": str, # 예탁자산평가액 + "tot_pur_amt": str, # 총매입금액 + "prsm_dpst_aset_amt": str, # 추정예탁자산 + "tot_grnt_sella": str, # 매도담보대출금 + "tdy_lspft_amt": str, # 당일투자원금 + "invt_bsamt": str, # 당월투자원금 + "lspft_amt": str, # 누적투자원금 + "tdy_lspft": str, # 당일투자손익 + "lspft2": str, # 당월투자손익 + "lspft": str, # 누적투자손익 + "tdy_lspft_rt": str, # 당일손익율 + "lspft_ratio": str, # 당월손익율 + "lspft_rt": str, # 누적손익율 + "stk_acnt_evlt_prst": [ # 종목별계좌평가현황 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "rmnd_qty": str, # 보유수량 + "avg_prc": str, # 평균단가 + "cur_prc": str, # 현재가 + "evlt_amt": str, # 평가금액 + "pl_amt": str, # 손익금액 + "pl_rt": str, # 손익율 + "loan_dt": str, # 대출일 + "pur_amt": str, # 매입금액 + "setl_remn": str, # 결제잔고 + "pred_buyq": str, # 전일매수수량 + "pred_sellq": str, # 전일매도수량 + "tdy_buyq": str, # 금일매수수량 + "tdy_sellq": str, # 금일매도수량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.account_evaluation_status_request_kt00004( + ... qry_tp="0", # 전체 조회 + ... dmst_stex_tp="KRX" # 한국거래소 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00004", + } + data = { + "qry_tp": qry_tp, + "dmst_stex_tp": dmst_stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def filled_position_request_kt00005( + self, + dmst_stex_tp: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 체결잔고요청 (kt00005) + + Args: + dmst_stex_tp (str): 국내거래소구분 (KRX:한국거래소, NXT:넥스트트레이드) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 체결잔고 데이터 + { + "entr": str, # 예수금 + "entr_d1": str, # 예수금D+1 + "entr_d2": str, # 예수금D+2 + "pymn_alow_amt": str, # 출금가능금액 + "uncl_stk_amt": str, # 미수확보금 + "repl_amt": str, # 대용금 + "rght_repl_amt": str, # 권리대용금 + "ord_alowa": str, # 주문가능현금 + "ch_uncla": str, # 현금미수금 + "crd_int_npay_gold": str, # 신용이자미납금 + "etc_loana": str, # 기타대여금 + "nrpy_loan": str, # 미상환융자금 + "profa_ch": str, # 증거금현금 + "repl_profa": str, # 증거금대용 + "stk_buy_tot_amt": str, # 주식매수총액 + "evlt_amt_tot": str, # 평가금액합계 + "tot_pl_tot": str, # 총손익합계 + "tot_pl_rt": str, # 총손익률 + "tot_re_buy_alowa": str, # 총재매수가능금액 + "20ord_alow_amt": str, # 20%주문가능금액 + "30ord_alow_amt": str, # 30%주문가능금액 + "40ord_alow_amt": str, # 40%주문가능금액 + "50ord_alow_amt": str, # 50%주문가능금액 + "60ord_alow_amt": str, # 60%주문가능금액 + "100ord_alow_amt": str, # 100%주문가능금액 + "crd_loan_tot": str, # 신용융자합계 + "crd_loan_ls_tot": str, # 신용융자대주합계 + "crd_grnt_rt": str, # 신용담보비율 + "dpst_grnt_use_amt_amt": str, # 예탁담보대출금액 + "grnt_loan_amt": str, # 매도담보대출금액 + "stk_cntr_remn": [ # 종목별체결잔고 + { + "crd_tp": str, # 신용구분 + "loan_dt": str, # 대출일 + "expr_dt": str, # 만기일 + "stk_cd": str, # 종목번호 + "stk_nm": str, # 종목명 + "setl_remn": str, # 결제잔고 + "cur_qty": str, # 현재잔고 + "cur_prc": str, # 현재가 + "buy_uv": str, # 매입단가 + "pur_amt": str, # 매입금액 + "evlt_amt": str, # 평가금액 + "evltv_prft": str, # 평가손익 + "pl_rt": str, # 손익률 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.filled_position_request_kt00005( + ... dmst_stex_tp="KRX" # 한국거래소 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00005", + } + data = { + "dmst_stex_tp": dmst_stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def account_order_execution_detail_request_kt00007( + self, + qry_tp: str, + stk_bond_tp: str, + sell_tp: str, + dmst_stex_tp: str, + ord_dt: str = "", + stock_code: str = "", + fr_ord_no: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 계좌별주문체결내역상세요청 (kt00007) + + Args: + qry_tp (str): 조회구분 (1:주문순, 2:역순, 3:미체결, 4:체결내역만) + stk_bond_tp (str): 주식채권구분 (0:전체, 1:주식, 2:채권) + sell_tp (str): 매도수구분 (0:전체, 1:매도, 2:매수) + dmst_stex_tp (str): 국내거래소구분 (%:전체, KRX:한국거래소, NXT:넥스트트레이드, SOR:최선주문집행) + ord_dt (str, optional): 주문일자 (YYYYMMDD). Defaults to "". + stock_code (str, optional): 종목코드 (12자리). 공백일때 전체종목. Defaults to "". + fr_ord_no (str, optional): 시작주문번호 (7자리). 공백일때 전체주문. Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 계좌별주문체결내역상세 데이터 + { + "acnt_ord_cntr_prps_dtl": [ # 계좌별주문체결내역상세 + { + "ord_no": str, # 주문번호 + "stk_cd": str, # 종목번호 + "trde_tp": str, # 매매구분 + "crd_tp": str, # 신용구분 + "ord_qty": str, # 주문수량 + "ord_uv": str, # 주문단가 + "cnfm_qty": str, # 확인수량 + "acpt_tp": str, # 접수구분 + "rsrv_tp": str, # 반대여부 + "ord_tm": str, # 주문시간 + "ori_ord": str, # 원주문 + "stk_nm": str, # 종목명 + "io_tp_nm": str, # 주문구분 + "loan_dt": str, # 대출일 + "cntr_qty": str, # 체결수량 + "cntr_uv": str, # 체결단가 + "ord_remnq": str, # 주문잔량 + "comm_ord_tp": str, # 통신구분 + "mdfy_cncl": str, # 정정취소 + "cnfm_tm": str, # 확인시간 + "dmst_stex_tp": str, # 국내거래소구분 + "cond_uv": str, # 스톱가 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.account_order_execution_detail_request_kt00007( + ... qry_tp="1", # 주문순 + ... stk_bond_tp="0", # 전체 + ... sell_tp="0", # 전체 + ... dmst_stex_tp="%", # 전체 + ... stock_code="005930" # 삼성전자 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00007", + } + data = { + "qry_tp": qry_tp, + "stk_bond_tp": stk_bond_tp, + "sell_tp": sell_tp, + "dmst_stex_tp": dmst_stex_tp, + } + + if ord_dt: + data["ord_dt"] = ord_dt + if stock_code: + data["stk_cd"] = stock_code + if fr_ord_no: + data["fr_ord_no"] = fr_ord_no + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def next_day_settlement_schedule_request_kt00008( + self, + strt_dcd_seq: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 계좌별익일결제예정내역요청 (kt00008) + + Args: + strt_dcd_seq (str, optional): 시작결제번호 (7자리). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 계좌별익일결제예정내역 데이터 + { + "trde_dt": str, # 매매일자 + "setl_dt": str, # 결제일자 + "sell_amt_sum": str, # 매도정산합 + "buy_amt_sum": str, # 매수정산합 + "acnt_nxdy_setl_frcs_prps_array": [ # 계좌별익일결제예정내역배열 + { + "seq": str, # 일련번호 + "stk_cd": str, # 종목번호 + "loan_dt": str, # 대출일 + "qty": str, # 수량 + "engg_amt": str, # 약정금액 + "cmsn": str, # 수수료 + "incm_tax": str, # 소득세 + "rstx": str, # 농특세 + "stk_nm": str, # 종목명 + "sell_tp": str, # 매도수구분 + "unp": str, # 단가 + "exct_amt": str, # 정산금액 + "trde_tax": str, # 거래세 + "resi_tax": str, # 주민세 + "crd_tp": str, # 신용구분 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.next_day_settlement_schedule_request_kt00008() + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00008", + } + data = {} + + if strt_dcd_seq: + data["strt_dcd_seq"] = strt_dcd_seq + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def account_order_execution_status_request_kt00009( + self, + stk_bond_tp: str, + mrkt_tp: str, + sell_tp: str, + qry_tp: str, + dmst_stex_tp: str, + ord_dt: str = "", + stock_code: str = "", + fr_ord_no: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 계좌별주문체결현황요청 (kt00009) + + Args: + stk_bond_tp (str): 주식채권구분 (0:전체, 1:주식, 2:채권) + mrkt_tp (str): 시장구분 (0:전체, 1:코스피, 2:코스닥, 3:OTCBB, 4:ECN) + sell_tp (str): 매도수구분 (0:전체, 1:매도, 2:매수) + qry_tp (str): 조회구분 (0:전체, 1:체결) + dmst_stex_tp (str): 국내거래소구분 (%:전체, KRX:한국거래소, NXT:넥스트트레이드, SOR:최선주문집행) + ord_dt (str, optional): 주문일자 (YYYYMMDD). Defaults to "". + stock_code (str, optional): 종목코드 (12자리). Defaults to "". + fr_ord_no (str, optional): 시작주문번호 (7자리). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 계좌별주문체결현황 데이터 + { + "sell_grntl_engg_amt": str, # 매도약정금액 + "buy_engg_amt": str, # 매수약정금액 + "engg_amt": str, # 약정금액 + "acnt_ord_cntr_prst_array": [ # 계좌별주문체결현황배열 + { + "stk_bond_tp": str, # 주식채권구분 + "ord_no": str, # 주문번호 + "stk_cd": str, # 종목번호 + "trde_tp": str, # 매매구분 + "io_tp_nm": str, # 주문유형구분 + "ord_qty": str, # 주문수량 + "ord_uv": str, # 주문단가 + "cnfm_qty": str, # 확인수량 + "rsrv_oppo": str, # 예약/반대 + "cntr_no": str, # 체결번호 + "acpt_tp": str, # 접수구분 + "orig_ord_no": str, # 원주문번호 + "stk_nm": str, # 종목명 + "setl_tp": str, # 결제구분 + "crd_deal_tp": str, # 신용거래구분 + "cntr_qty": str, # 체결수량 + "cntr_uv": str, # 체결단가 + "comm_ord_tp": str, # 통신구분 + "mdfy_cncl_tp": str, # 정정/취소구분 + "cntr_tm": str, # 체결시간 + "dmst_stex_tp": str, # 국내거래소구분 + "cond_uv": str, # 스톱가 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.account_order_execution_status_request_kt00009( + ... stk_bond_tp="0", # 전체 + ... mrkt_tp="0", # 전체 + ... sell_tp="0", # 전체 + ... qry_tp="0", # 전체 + ... dmst_stex_tp="KRX" # 한국거래소 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00009", + } + data = { + "stk_bond_tp": stk_bond_tp, + "mrkt_tp": mrkt_tp, + "sell_tp": sell_tp, + "qry_tp": qry_tp, + "dmst_stex_tp": dmst_stex_tp, + } + + if ord_dt: + data["ord_dt"] = ord_dt + if stock_code: + data["stk_cd"] = stock_code + if fr_ord_no: + data["fr_ord_no"] = fr_ord_no + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def withdrawable_order_amount_request_kt00010( + self, + stock_code: str, + trde_tp: str, + uv: str, + io_amt: str = "", + trde_qty: str = "", + exp_buy_unp: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 주문인출가능금액요청 (kt00010) + + Args: + stock_code (str): 종목번호 (12자리) + trde_tp (str): 매매구분 (1:매도, 2:매수) + uv (str): 매수가격 (10자리) + io_amt (str, optional): 입출금액 (12자리). Defaults to "". + trde_qty (str, optional): 매매수량 (10자리). Defaults to "". + exp_buy_unp (str, optional): 예상매수단가 (10자리). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 주문인출가능금액 데이터 + { + "profa_20ord_alow_amt": str, # 증거금20%주문가능금액 + "profa_20ord_alowq": str, # 증거금20%주문가능수량 + "profa_30ord_alow_amt": str, # 증거금30%주문가능금액 + "profa_30ord_alowq": str, # 증거금30%주문가능수량 + "profa_40ord_alow_amt": str, # 증거금40%주문가능금액 + "profa_40ord_alowq": str, # 증거금40%주문가능수량 + "profa_50ord_alow_amt": str, # 증거금50%주문가능금액 + "profa_50ord_alowq": str, # 증거금50%주문가능수량 + "profa_60ord_alow_amt": str, # 증거금60%주문가능금액 + "profa_60ord_alowq": str, # 증거금60%주문가능수량 + "profa_rdex_60ord_alow_amt": str, # 증거금감면60%주문가능금 + "profa_rdex_60ord_alowq": str, # 증거금감면60%주문가능수 + "profa_100ord_alow_amt": str, # 증거금100%주문가능금액 + "profa_100ord_alowq": str, # 증거금100%주문가능수량 + "pred_reu_alowa": str, # 전일재사용가능금액 + "tdy_reu_alowa": str, # 금일재사용가능금액 + "entr": str, # 예수금 + "repl_amt": str, # 대용금 + "uncla": str, # 미수금 + "ord_pos_repl": str, # 주문가능대용 + "ord_alowa": str, # 주문가능현금 + "wthd_alowa": str, # 인출가능금액 + "nxdy_wthd_alowa": str, # 익일인출가능금액 + "pur_amt": str, # 매입금액 + "cmsn": str, # 수수료 + "pur_exct_amt": str, # 매입정산금 + "d2entra": str, # D2추정예수금 + "profa_rdex_aplc_tp": str, # 증거금감면적용구분 (0:일반,1:60%감면) + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.withdrawable_order_amount_request_kt00010( + ... stock_code="005930", # 삼성전자 + ... trde_tp="2", # 매수 + ... uv="267000" # 매수가격 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00010", + } + data = { + "stk_cd": stock_code, + "trde_tp": trde_tp, + "uv": uv, + } + + if io_amt: + data["io_amt"] = io_amt + if trde_qty: + data["trde_qty"] = trde_qty + if exp_buy_unp: + data["exp_buy_unp"] = exp_buy_unp + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def orderable_quantity_by_margin_ratio_request_kt00011( + self, + stock_code: str, + uv: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 증거금율별주문가능수량조회요청 (kt00011) + + Args: + stock_code (str): 종목번호 (12자리) + uv (str, optional): 매수가격 (10자리). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 증거금율별주문가능수량 데이터 + { + "stk_profa_rt": str, # 종목증거금율 + "profa_rt": str, # 계좌증거금율 + "aplc_rt": str, # 적용증거금율 + "profa_20ord_alow_amt": str, # 증거금20%주문가능금액 + "profa_20ord_alowq": str, # 증거금20%주문가능수량 + "profa_20pred_reu_amt": str, # 증거금20%전일재사용금액 + "profa_20tdy_reu_amt": str, # 증거금20%금일재사용금액 + "profa_30ord_alow_amt": str, # 증거금30%주문가능금액 + "profa_30ord_alowq": str, # 증거금30%주문가능수량 + "profa_30pred_reu_amt": str, # 증거금30%전일재사용금액 + "profa_30tdy_reu_amt": str, # 증거금30%금일재사용금액 + "profa_40ord_alow_amt": str, # 증거금40%주문가능금액 + "profa_40ord_alowq": str, # 증거금40%주문가능수량 + "profa_40pred_reu_amt": str, # 증거금40%전일재사용금액 + "profa_40tdy_reu_amt": str, # 증거금40%금일재사용금액 + "profa_50ord_alow_amt": str, # 증거금50%주문가능금액 + "profa_50ord_alowq": str, # 증거금50%주문가능수량 + "profa_50pred_reu_amt": str, # 증거금50%전일재사용금액 + "profa_50tdy_reu_amt": str, # 증거금50%금일재사용금액 + "profa_60ord_alow_amt": str, # 증거금60%주문가능금액 + "profa_60ord_alowq": str, # 증거금60%주문가능수량 + "profa_60pred_reu_amt": str, # 증거금60%전일재사용금액 + "profa_60tdy_reu_amt": str, # 증거금60%금일재사용금액 + "profa_100ord_alow_amt": str, # 증거금100%주문가능금액 + "profa_100ord_alowq": str, # 증거금100%주문가능수량 + "profa_100pred_reu_amt": str, # 증거금100%전일재사용금액 + "profa_100tdy_reu_amt": str, # 증거금100%금일재사용금액 + "min_ord_alow_amt": str, # 미수불가주문가능금액 + "min_ord_alowq": str, # 미수불가주문가능수량 + "min_pred_reu_amt": str, # 미수불가전일재사용금액 + "min_tdy_reu_amt": str, # 미수불가금일재사용금액 + "entr": str, # 예수금 + "repl_amt": str, # 대용금 + "uncla": str, # 미수금 + "ord_pos_repl": str, # 주문가능대용 + "ord_alowa": str, # 주문가능현금 + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.orderable_quantity_by_margin_ratio_request_kt00011( + ... stock_code="005930" # 삼성전자 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00011", + } + data = { + "stk_cd": stock_code, + } + + if uv: + data["uv"] = uv + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def orderable_quantity_by_credit_guarantee_ratio_request_kt00012( + self, + stock_code: str, + uv: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 신용보증금율별주문가능수량조회요청 (kt00012) + + Args: + stock_code (str): 종목번호 (12자리) + uv (str, optional): 매수가격 (10자리). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 신용보증금율별주문가능수량 데이터 + { + "stk_assr_rt": str, # 종목보증금율 + "stk_assr_rt_nm": str, # 종목보증금율명 + "assr_30ord_alow_amt": str, # 보증금30%주문가능금액 + "assr_30ord_alowq": str, # 보증금30%주문가능수량 + "assr_30pred_reu_amt": str, # 보증금30%전일재사용금액 + "assr_30tdy_reu_amt": str, # 보증금30%금일재사용금액 + "assr_40ord_alow_amt": str, # 보증금40%주문가능금액 + "assr_40ord_alowq": str, # 보증금40%주문가능수량 + "assr_40pred_reu_amt": str, # 보증금40%전일재사용금액 + "assr_40tdy_reu_amt": str, # 보증금40%금일재사용금액 + "assr_50ord_alow_amt": str, # 보증금50%주문가능금액 + "assr_50ord_alowq": str, # 보증금50%주문가능수량 + "assr_50pred_reu_amt": str, # 보증금50%전일재사용금액 + "assr_50tdy_reu_amt": str, # 보증금50%금일재사용금액 + "assr_60ord_alow_amt": str, # 보증금60%주문가능금액 + "assr_60ord_alowq": str, # 보증금60%주문가능수량 + "assr_60pred_reu_amt": str, # 보증금60%전일재사용금액 + "assr_60tdy_reu_amt": str, # 보증금60%금일재사용금액 + "entr": str, # 예수금 + "repl_amt": str, # 대용금 + "uncla": str, # 미수금 + "ord_pos_repl": str, # 주문가능대용 + "ord_alowa": str, # 주문가능현금 + "out_alowa": str, # 미수가능금액 + "out_pos_qty": str, # 미수가능수량 + "min_amt": str, # 미수불가금액 + "min_qty": str, # 미수불가수량 + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.orderable_quantity_by_credit_guarantee_ratio_request_kt00012( + ... stock_code="005930" # 삼성전자 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00012", + } + data = { + "stk_cd": stock_code, + } + + if uv: + data["uv"] = uv + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def margin_detail_inquiry_request_kt00013( + self, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 증거금세부내역조회요청 (kt00013) + + Args: + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 증거금세부내역 데이터 + { + "tdy_reu_objt_amt": str, # 금일재사용대상금액 + "tdy_reu_use_amt": str, # 금일재사용사용금액 + "tdy_reu_alowa": str, # 금일재사용가능금액 + "tdy_reu_lmtt_amt": str, # 금일재사용제한금액 + "tdy_reu_alowa_fin": str, # 금일재사용가능금액최종 + "pred_reu_objt_amt": str, # 전일재사용대상금액 + "pred_reu_use_amt": str, # 전일재사용사용금액 + "pred_reu_alowa": str, # 전일재사용가능금액 + "pred_reu_lmtt_amt": str, # 전일재사용제한금액 + "pred_reu_alowa_fin": str, # 전일재사용가능금액최종 + "ch_amt": str, # 현금금액 + "ch_profa": str, # 현금증거금 + "use_pos_ch": str, # 사용가능현금 + "ch_use_lmtt_amt": str, # 현금사용제한금액 + "use_pos_ch_fin": str, # 사용가능현금최종 + "repl_amt_amt": str, # 대용금액 + "repl_profa": str, # 대용증거금 + "use_pos_repl": str, # 사용가능대용 + "repl_use_lmtt_amt": str, # 대용사용제한금액 + "use_pos_repl_fin": str, # 사용가능대용최종 + "crd_grnta_ch": str, # 신용보증금현금 + "crd_grnta_repl": str, # 신용보증금대용 + "crd_grnt_ch": str, # 신용담보금현금 + "crd_grnt_repl": str, # 신용담보금대용 + "uncla": str, # 미수금 + "ls_grnt_reu_gold": str, # 대주담보금재사용금 + "20ord_alow_amt": str, # 20%주문가능금액 + "30ord_alow_amt": str, # 30%주문가능금액 + "40ord_alow_amt": str, # 40%주문가능금액 + "50ord_alow_amt": str, # 50%주문가능금액 + "60ord_alow_amt": str, # 60%주문가능금액 + "100ord_alow_amt": str, # 100%주문가능금액 + "tdy_crd_rpya_loss_amt": str, # 금일신용상환손실금액 + "pred_crd_rpya_loss_amt": str, # 전일신용상환손실금액 + "tdy_ls_rpya_loss_repl_profa": str, # 금일대주상환손실대용증거금 + "pred_ls_rpya_loss_repl_profa": str, # 전일대주상환손실대용증거금 + "evlt_repl_amt_spg_use_skip": str, # 평가대용금(현물사용제외) + "evlt_repl_rt": str, # 평가대용비율 + "crd_repl_profa": str, # 신용대용증거금 + "ch_ord_repl_profa": str, # 현금주문대용증거금 + "crd_ord_repl_profa": str, # 신용주문대용증거금 + "crd_repl_conv_gold": str, # 신용대용환산금 + "repl_alowa": str, # 대용가능금액(현금제한) + "repl_alowa_2": str, # 대용가능금액2(신용제한) + "ch_repl_lck_gold": str, # 현금대용부족금 + "crd_repl_lck_gold": str, # 신용대용부족금 + "ch_ord_alow_repla": str, # 현금주문가능대용금 + "crd_ord_alow_repla": str, # 신용주문가능대용금 + "d2vexct_entr": str, # D2가정산예수금 + "d2ch_ord_alow_amt": str, # D2현금주문가능금액 + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.margin_detail_inquiry_request_kt00013() + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00013", + } + data = {} + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def comprehensive_transaction_history_request_kt00015( + self, + start_date: str, + end_date: str, + transaction_type: str, + stock_code: str = "", + currency_code: str = "", + goods_type: str = "0", + foreign_exchange_code: str = "", + domestic_exchange_type: str = "%", + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 위탁종합거래내역요청 (kt00015) + + Args: + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + transaction_type (str): 구분 (0:전체,1:입출금,2:입출고,3:매매,4:매수,5:매도,6:입금,7:출금,A:예탁담보대출입금,B:매도담보대출입금,C:현금상환(융자,담보상환),F:환전,M:입출금+환전,G:외화매수,H:외화매도,I:환전정산입금,J:환전정산출금) + stock_code (str, optional): 종목코드 (12자리). Defaults to "". + currency_code (str, optional): 통화코드 (3자리). Defaults to "". + goods_type (str, optional): 상품구분 (0:전체, 1:국내주식, 2:수익증권, 3:해외주식, 4:금융상품). Defaults to "0". + foreign_exchange_code (str, optional): 해외거래소코드 (10자리). Defaults to "". + domestic_exchange_type (str, optional): 국내거래소구분 (%:전체,KRX:한국거래소,NXT:넥스트트레이드). Defaults to "%". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 위탁종합거래내역 데이터 + { + "acnt_no": str, # 계좌번호 + "trst_ovrl_trde_prps_array": [ # 위탁종합거래내역배열 + { + "trde_dt": str, # 거래일자 + "trde_no": str, # 거래번호 + "rmrk_nm": str, # 적요명 + "crd_deal_tp_nm": str, # 신용거래구분명 + "exct_amt": str, # 정산금액 + "loan_amt_rpya": str, # 대출금상환 + "fc_trde_amt": str, # 거래금액(외) + "fc_exct_amt": str, # 정산금액(외) + "entra_remn": str, # 예수금잔고 + "crnc_cd": str, # 통화코드 + "trde_ocr_tp": str, # 거래종류구분 + "trde_kind_nm": str, # 거래종류명 + "stk_nm": str, # 종목명 + "trde_amt": str, # 거래금액 + "trde_agri_tax": str, # 거래및농특세 + "rpy_diffa": str, # 상환차금 + "fc_trde_tax": str, # 거래세(외) + "dly_sum": str, # 연체합 + "fc_entra": str, # 외화예수금잔고 + "mdia_tp_nm": str, # 매체구분명 + "io_tp": str, # 입출구분 + "io_tp_nm": str, # 입출구분명 + "orig_deal_no": str, # 원거래번호 + "stk_cd": str, # 종목코드 + "trde_qty_jwa_cnt": str, # 거래수량/좌수 + "cmsn": str, # 수수료 + "int_ls_usfe": str, # 이자/대주이용 + "fc_cmsn": str, # 수수료(외) + "fc_dly_sum": str, # 연체합(외) + "vlbl_nowrm": str, # 유가금잔 + "proc_tm": str, # 처리시간 + "isin_cd": str, # ISIN코드 + "stex_cd": str, # 거래소코드 + "stex_nm": str, # 거래소명 + "trde_unit": str, # 거래단가/환율 + "incm_resi_tax": str, # 소득/주민세 + "loan_dt": str, # 대출일 + "uncl_ocr": str, # 미수(원/주) + "rpym_sum": str, # 변제합 + "cntr_dt": str, # 체결일 + "rcpy_no": str, # 출납번호 + "prcsr": str, # 처리자 + "proc_brch": str, # 처리점 + "trde_stle": str, # 매매형태 + "txon_base_pric": str, # 과세기준가 + "tax_sum_cmsn": str, # 세금수수료합 + "frgn_pay_txam": str, # 외국납부세액(외) + "fc_uncl_ocr": str, # 미수(외) + "rpym_sum_fr": str, # 변제합(외) + "rcpmnyer": str, # 입금자 + "trde_prtc_tp": str, # 거래내역구분 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.comprehensive_transaction_history_request_kt00015( + ... start_date="20241121", + ... end_date="20241125", + ... transaction_type="0" # 전체 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00015", + } + data = { + "strt_dt": start_date, + "end_dt": end_date, + "tp": transaction_type, + "stk_cd": stock_code, + "crnc_cd": currency_code, + "gds_tp": goods_type, + "frgn_stex_code": foreign_exchange_code, + "dmst_stex_tp": domestic_exchange_type, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def daily_account_return_detail_status_request_kt00016( + self, + from_date: str, + to_date: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 일별계좌수익률상세현황요청 (kt00016) + + Args: + from_date (str): 평가시작일 (YYYYMMDD) + to_date (str): 평가종료일 (YYYYMMDD) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 일별계좌수익률상세현황 데이터 + { + "mang_empno": str, # 관리사원번호 + "mngr_nm": str, # 관리자명 + "dept_nm": str, # 관리자지점 + "entr_fr": str, # 예수금_초 + "entr_to": str, # 예수금_말 + "scrt_evlt_amt_fr": str, # 유가증권평가금액_초 + "scrt_evlt_amt_to": str, # 유가증권평가금액_말 + "ls_grnt_fr": str, # 대주담보금_초 + "ls_grnt_to": str, # 대주담보금_말 + "crd_loan_fr": str, # 신용융자금_초 + "crd_loan_to": str, # 신용융자금_말 + "ch_uncla_fr": str, # 현금미수금_초 + "ch_uncla_to": str, # 현금미수금_말 + "krw_asgna_fr": str, # 원화대용금_초 + "krw_asgna_to": str, # 원화대용금_말 + "ls_evlta_fr": str, # 대주평가금_초 + "ls_evlta_to": str, # 대주평가금_말 + "rght_evlta_fr": str, # 권리평가금_초 + "rght_evlta_to": str, # 권리평가금_말 + "loan_amt_fr": str, # 대출금_초 + "loan_amt_to": str, # 대출금_말 + "etc_loana_fr": str, # 기타대여금_초 + "etc_loana_to": str, # 기타대여금_말 + "crd_int_npay_gold_fr": str, # 신용이자미납금_초 + "crd_int_npay_gold_to": str, # 신용이자미납금_말 + "crd_int_fr": str, # 신용이자_초 + "crd_int_to": str, # 신용이자_말 + "tot_amt_fr": str, # 순자산액계_초 + "tot_amt_to": str, # 순자산액계_말 + "invt_bsamt": str, # 투자원금평잔 + "evltv_prft": str, # 평가손익 + "prft_rt": str, # 수익률 + "tern_rt": str, # 회전율 + "termin_tot_trns": str, # 기간내총입금 + "termin_tot_pymn": str, # 기간내총출금 + "termin_tot_inq": str, # 기간내총입고 + "termin_tot_outq": str, # 기간내총출고 + "futr_repl_sella": str, # 선물대용매도금액 + "trst_repl_sella": str, # 위탁대용매도금액 + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.daily_account_return_detail_status_request_kt00016( + ... from_date="20241111", + ... to_date="20241125" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00016", + } + data = { + "fr_dt": from_date, + "to_dt": to_date, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def today_account_status_by_account_request_kt00017( + self, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 계좌별당일현황요청 (kt00017) + + Args: + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 계좌별당일현황 데이터 + { + "d2_entra": str, # D+2추정예수금 + "crd_int_npay_gold": str, # 신용이자미납금 + "etc_loana": str, # 기타대여금 + "gnrl_stk_evlt_amt_d2": str, # 일반주식평가금액D+2 + "dpst_grnt_use_amt_d2": str, # 예탁담보대출금D+2 + "crd_stk_evlt_amt_d2": str, # 예탁담보주식평가금액D+2 + "crd_loan_d2": str, # 신용융자금D+2 + "crd_loan_evlta_d2": str, # 신용융자평가금D+2 + "crd_ls_grnt_d2": str, # 신용대주담보금D+2 + "crd_ls_evlta_d2": str, # 신용대주평가금D+2 + "ina_amt": str, # 입금금액 + "outa": str, # 출금금액 + "inq_amt": str, # 입고금액 + "outq_amt": str, # 출고금액 + "sell_amt": str, # 매도금액 + "buy_amt": str, # 매수금액 + "cmsn": str, # 수수료 + "tax": str, # 세금 + "stk_pur_cptal_loan_amt": str, # 주식매입자금대출금 + "rp_evlt_amt": str, # RP평가금액 + "bd_evlt_amt": str, # 채권평가금액 + "elsevlt_amt": str, # ELS평가금액 + "crd_int_amt": str, # 신용이자금액 + "sel_prica_grnt_loan_int_amt_amt": str, # 매도대금담보대출이자금액 + "dvida_amt": str, # 배당금액 + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.today_account_status_by_account_request_kt00017() + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00017", + } + data = {} + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def account_evaluation_balance_detail_request_kt00018( + self, + query_type: str, + domestic_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> dict: + """ + 계좌평가잔고내역요청 (kt00018) + + Args: + query_type (str): 조회구분 (1:합산, 2:개별) + domestic_exchange_type (str): 국내거래소구분 (KRX:한국거래소,NXT:넥스트트레이드) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 계좌평가잔고내역 데이터 + { + "tot_pur_amt": str, # 총매입금액 + "tot_evlt_amt": str, # 총평가금액 + "tot_evlt_pl": str, # 총평가손익금액 + "tot_prft_rt": str, # 총수익률(%) + "prsm_dpst_aset_amt": str, # 추정예탁자산 + "tot_loan_amt": str, # 총대출금 + "tot_crd_loan_amt": str, # 총융자금액 + "tot_crd_ls_amt": str, # 총대주금액 + "acnt_evlt_remn_indv_tot": [ # 계좌평가잔고개별합산 + { + "stk_cd": str, # 종목번호 + "stk_nm": str, # 종목명 + "evltv_prft": str, # 평가손익 + "prft_rt": str, # 수익률(%) + "pur_pric": str, # 매입가 + "pred_close_pric": str, # 전일종가 + "rmnd_qty": str, # 보유수량 + "trde_able_qty": str, # 매매가능수량 + "cur_prc": str, # 현재가 + "pred_buyq": str, # 전일매수수량 + "pred_sellq": str, # 전일매도수량 + "tdy_buyq": str, # 금일매수수량 + "tdy_sellq": str, # 금일매도수량 + "pur_amt": str, # 매입금액 + "pur_cmsn": str, # 매입수수료 + "evlt_amt": str, # 평가금액 + "sell_cmsn": str, # 평가수수료 + "tax": str, # 세금 + "sum_cmsn": str, # 수수료합 + "poss_rt": str, # 보유비중(%) + "crd_tp": str, # 신용구분 + "crd_tp_nm": str, # 신용구분명 + "crd_loan_dt": str, # 대출일 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.account.account_evaluation_balance_detail_request_kt00018( + ... query_type="1", # 합산 + ... domestic_exchange_type="KRX" # 한국거래소 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "kt00018", + } + data = { + "qry_tp": query_type, + "dmst_stex_tp": domestic_exchange_type, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/analysis.py b/kiwoom_rest_api/koreanstock/analysis.py new file mode 100644 index 0000000..ccf1fb4 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/analysis.py @@ -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, + ) diff --git a/kiwoom_rest_api/koreanstock/chart.py b/kiwoom_rest_api/koreanstock/chart.py new file mode 100644 index 0000000..219a814 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/chart.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/credit_order.py b/kiwoom_rest_api/koreanstock/credit_order.py new file mode 100644 index 0000000..5c36e13 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/credit_order.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/elw.py b/kiwoom_rest_api/koreanstock/elw.py new file mode 100644 index 0000000..f6d185b --- /dev/null +++ b/kiwoom_rest_api/koreanstock/elw.py @@ -0,0 +1,1113 @@ +from kiwoom_rest_api.core.base_api import KiwoomBaseAPI +from typing import Union, Dict, Any, Awaitable + +class ELW(KiwoomBaseAPI): + """한국 주식 ELW 관련 API를 제공하는 클래스""" + + def __init__( + self, + base_url: str = None, + token_manager=None, + use_async: bool = False, + resource_url: str = "/api/dostk/elw" + ): + """ + ELW 클래스 초기화 + + 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 elw_daily_sensitivity_indicator_request_ka10048( + self, + stk_cd: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 일별 민감도 지표를 조회합니다. + + Args: + stk_cd (str): 종목코드 (예: "57JBHH") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 일별 민감도 지표 데이터 + { + "elwdaly_snst_ix": [ # ELW일별민감도지표 + { + "dt": str, # 일자 + "iv": str, # IV (Implied Volatility) + "delta": str, # 델타 + "gam": str, # 감마 + "theta": str, # 쎄타 + "vega": str, # 베가 + "law": str, # 로 + "lp": str, # LP + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_daily_sensitivity_indicator_request_ka10048( + ... stk_cd="57JBHH" # ELW 종목코드 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10048", + } + + data = { + "stk_cd": stk_cd, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_sensitivity_indicator_request_ka10050( + self, + stk_cd: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 민감도 지표를 조회합니다. + + Args: + stk_cd (str): 종목코드 (예: "57JBHH") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 민감도 지표 데이터 + { + "elwsnst_ix_array": [ # ELW민감도지표배열 + { + "cntr_tm": str, # 체결시간 + "cur_prc": str, # 현재가 + "elwtheory_pric": str, # ELW이론가 + "iv": str, # IV (Implied Volatility) + "delta": str, # 델타 + "gam": str, # 감마 + "theta": str, # 쎄타 + "vega": str, # 베가 + "law": str, # 로 + "lp": str, # LP + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_sensitivity_indicator_request_ka10050( + ... stk_cd="57JBHH" # ELW 종목코드 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10050", + } + + data = { + "stk_cd": stk_cd, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_price_spike_request_ka30001( + self, + flu_tp: str, + tm_tp: str, + tm: str, + trde_qty_tp: str, + isscomp_cd: str, + bsis_aset_cd: str, + rght_tp: str, + lpcd: str, + trde_end_elwskip: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 가격 급등락 정보를 조회합니다. + + Args: + flu_tp (str): 등락구분 (1:급등, 2:급락) + tm_tp (str): 시간구분 (1:분전, 2:일전) + tm (str): 시간 (분 혹은 일입력, 예: 1, 3, 5) + trde_qty_tp (str): 거래량구분 + - 0: 전체 + - 10: 만주이상 + - 50: 5만주이상 + - 100: 10만주이상 + - 300: 30만주이상 + - 500: 50만주이상 + - 1000: 백만주이상 + isscomp_cd (str): 발행사코드 + - 000000000000: 전체 + - 3: 한국투자증권 + - 5: 미래대우 + - 6: 신영 + - 12: NK투자증권 + - 17: KB증권 + bsis_aset_cd (str): 기초자산코드 + - 000000000000: 전체 + - 201: KOSPI200 + - 150: KOSDAQ150 + - 005930: 삼성전자 + - 030200: KT + rght_tp (str): 권리구분 + - 000: 전체 + - 001: 콜 + - 002: 풋 + - 003: DC + - 004: DP + - 005: EX + - 006: 조기종료콜 + - 007: 조기종료풋 + lpcd (str): LP코드 + - 000000000000: 전체 + - 3: 한국투자증권 + - 5: 미래대우 + - 6: 신영 + - 12: NK투자증권 + - 17: KB증권 + trde_end_elwskip (str): 거래종료ELW제외 (0:포함, 1:제외) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 가격 급등락 데이터 + { + "base_pric_tm": str, # 기준가시간 + "elwpric_jmpflu": [ # ELW가격급등락 + { + "stk_cd": str, # 종목코드 + "rank": str, # 순위 + "stk_nm": str, # 종목명 + "pre_sig": str, # 대비기호 + "pred_pre": str, # 전일대비 + "trde_end_elwbase_pric": str, # 거래종료ELW기준가 + "cur_prc": str, # 현재가 + "base_pre": str, # 기준대비 + "trde_qty": str, # 거래량 + "jmp_rt": str, # 급등율 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_price_spike_request_ka30001( + ... flu_tp="1", # 급등 + ... tm_tp="2", # 일전 + ... tm="1", # 1일 + ... trde_qty_tp="0", # 전체 + ... isscomp_cd="000000000000", # 전체 + ... bsis_aset_cd="000000000000", # 전체 + ... rght_tp="000", # 전체 + ... lpcd="000000000000", # 전체 + ... trde_end_elwskip="0" # 포함 + ... ) + """ + # 파라미터 유효성 검증 + if flu_tp not in ["1", "2"]: + raise ValueError("flu_tp must be '1' (급등) or '2' (급락)") + if tm_tp not in ["1", "2"]: + raise ValueError("tm_tp must be '1' (분전) or '2' (일전)") + if not tm.isdigit() or len(tm) > 2: + raise ValueError("tm must be a 1-2 digit number") + if trde_qty_tp not in ["0", "10", "50", "100", "300", "500", "1000"]: + raise ValueError("Invalid trde_qty_tp value") + if not isscomp_cd.isdigit() or len(isscomp_cd) != 12: + raise ValueError("isscomp_cd must be a 12-digit number") + if not bsis_aset_cd.isdigit() or len(bsis_aset_cd) != 12: + raise ValueError("bsis_aset_cd must be a 12-digit number") + if rght_tp not in ["000", "001", "002", "003", "004", "005", "006", "007"]: + raise ValueError("Invalid rght_tp value") + if not lpcd.isdigit() or len(lpcd) != 12: + raise ValueError("lpcd must be a 12-digit number") + if trde_end_elwskip not in ["0", "1"]: + raise ValueError("trde_end_elwskip must be '0' (포함) or '1' (제외)") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30001", + } + + data = { + "flu_tp": flu_tp, + "tm_tp": tm_tp, + "tm": tm, + "trde_qty_tp": trde_qty_tp, + "isscomp_cd": isscomp_cd, + "bsis_aset_cd": bsis_aset_cd, + "rght_tp": rght_tp, + "lpcd": lpcd, + "trde_end_elwskip": trde_end_elwskip, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_elw_net_buying_by_broker_request_ka30002( + self, + isscomp_cd: str, + trde_qty_tp: str, + trde_tp: str, + dt: str, + trde_end_elwskip: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """거래원별 ELW 순매매 상위 정보를 조회합니다. + + Args: + isscomp_cd (str): 발행사코드 (3자리) + - 001: 교보 + - 002: 신한금융투자 + - 003: 한국투자증권 + - 004: 대신 + - 005: 미래대우 + - 기타: 영웅문4 0273화면 참조 + trde_qty_tp (str): 거래량구분 + - 0: 전체 + - 5: 5천주 + - 10: 만주 + - 50: 5만주 + - 100: 10만주 + - 500: 50만주 + - 1000: 백만주 + trde_tp (str): 매매구분 + - 1: 순매수 + - 2: 순매도 + dt (str): 기간 + - 1: 전일 + - 5: 5일 + - 10: 10일 + - 40: 40일 + - 60: 60일 + trde_end_elwskip (str): 거래종료ELW제외 + - 0: 포함 + - 1: 제외 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 거래원별 ELW 순매매 상위 데이터 + { + "trde_ori_elwnettrde_upper": [ # 거래원별ELW순매매상위 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "stkpc_flu": str, # 주가등락 + "flu_rt": str, # 등락율 + "trde_qty": str, # 거래량 + "netprps": str, # 순매수 + "buy_trde_qty": str, # 매수거래량 + "sel_trde_qty": str, # 매도거래량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.top_elw_net_buying_by_broker_request_ka30002( + ... isscomp_cd="003", # 한국투자증권 + ... trde_qty_tp="0", # 전체 + ... trde_tp="2", # 순매도 + ... dt="60", # 60일 + ... trde_end_elwskip="0" # 포함 + ... ) + """ + # 파라미터 유효성 검증 + if not isscomp_cd.isdigit() or len(isscomp_cd) != 3: + raise ValueError("isscomp_cd must be a 3-digit number") + if trde_qty_tp not in ["0", "5", "10", "50", "100", "500", "1000"]: + raise ValueError("Invalid trde_qty_tp value") + if trde_tp not in ["1", "2"]: + raise ValueError("trde_tp must be '1' (순매수) or '2' (순매도)") + if dt not in ["1", "5", "10", "40", "60"]: + raise ValueError("dt must be one of: '1', '5', '10', '40', '60'") + if trde_end_elwskip not in ["0", "1"]: + raise ValueError("trde_end_elwskip must be '0' (포함) or '1' (제외)") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30002", + } + + data = { + "isscomp_cd": isscomp_cd, + "trde_qty_tp": trde_qty_tp, + "trde_tp": trde_tp, + "dt": dt, + "trde_end_elwskip": trde_end_elwskip, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_lp_daily_holding_trend_request_ka30003( + self, + bsis_aset_cd: str, + base_dt: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW LP 보유 일별 추이 정보를 조회합니다. + + Args: + bsis_aset_cd (str): 기초자산코드 (12자리) + base_dt (str): 기준일자 (YYYYMMDD 형식) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW LP 보유 일별 추이 데이터 + { + "elwlpposs_daly_trnsn": [ # ELWLP보유일별추이 + { + "dt": str, # 일자 + "cur_prc": str, # 현재가 + "pre_tp": str, # 대비구분 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락율 + "trde_qty": str, # 거래량 + "trde_prica": str, # 거래대금 + "chg_qty": str, # 변동수량 + "lprmnd_qty": str, # LP보유수량 + "wght": str, # 비중 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_lp_daily_holding_trend_request_ka30003( + ... bsis_aset_cd="57KJ99", # 기초자산코드 + ... base_dt="20241122" # 기준일자 + ... ) + """ + # 파라미터 유효성 검증 + if not bsis_aset_cd or len(bsis_aset_cd) != 12: + raise ValueError("bsis_aset_cd must be a 12-character string") + + # base_dt 형식 검증 (YYYYMMDD) + if not base_dt.isdigit() or len(base_dt) != 8: + raise ValueError("base_dt must be in YYYYMMDD format") + try: + year = int(base_dt[:4]) + month = int(base_dt[4:6]) + day = int(base_dt[6:8]) + if not (1 <= month <= 12 and 1 <= day <= 31): + raise ValueError + except ValueError: + raise ValueError("base_dt must be a valid date in YYYYMMDD format") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30003", + } + + data = { + "bsis_aset_cd": bsis_aset_cd, + "base_dt": base_dt, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_premium_rate_request_ka30004( + self, + isscomp_cd: str, + bsis_aset_cd: str, + rght_tp: str, + lpcd: str, + trde_end_elwskip: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 괴리율 정보를 조회합니다. + + Args: + isscomp_cd (str): 발행사코드 (12자리) + - 000000000000: 전체 + - 3: 한국투자증권 + - 5: 미래대우 + - 6: 신영 + - 12: NK투자증권 + - 17: KB증권 + bsis_aset_cd (str): 기초자산코드 (12자리) + - 000000000000: 전체 + - 201: KOSPI200 + - 150: KOSDAQ150 + - 005930: 삼성전자 + - 030200: KT + rght_tp (str): 권리구분 (3자리) + - 000: 전체 + - 001: 콜 + - 002: 풋 + - 003: DC + - 004: DP + - 005: EX + - 006: 조기종료콜 + - 007: 조기종료풋 + lpcd (str): LP코드 (12자리) + - 000000000000: 전체 + - 3: 한국투자증권 + - 5: 미래대우 + - 6: 신영 + - 12: NK투자증권 + - 17: KB증권 + trde_end_elwskip (str): 거래종료ELW제외 + - 0: 거래종료ELW포함 + - 1: 거래종료ELW제외 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 괴리율 데이터 + { + "elwdispty_rt": [ # ELW괴리율 + { + "stk_cd": str, # 종목코드 + "isscomp_nm": str, # 발행사명 + "sqnc": str, # 회차 + "base_aset_nm": str, # 기초자산명 + "rght_tp": str, # 권리구분 + "dispty_rt": str, # 괴리율 + "basis": str, # 베이시스 + "srvive_dys": str, # 잔존일수 + "theory_pric": str, # 이론가 + "cur_prc": str, # 현재가 + "pre_tp": str, # 대비구분 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락율 + "trde_qty": str, # 거래량 + "stk_nm": str, # 종목명 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_premium_rate_request_ka30004( + ... isscomp_cd="000000000000", # 전체 + ... bsis_aset_cd="000000000000", # 전체 + ... rght_tp="000", # 전체 + ... lpcd="000000000000", # 전체 + ... trde_end_elwskip="0" # 거래종료ELW포함 + ... ) + """ + # 파라미터 유효성 검증 + if not isscomp_cd.isdigit() or len(isscomp_cd) != 12: + raise ValueError("isscomp_cd must be a 12-digit number") + if not bsis_aset_cd.isdigit() or len(bsis_aset_cd) != 12: + raise ValueError("bsis_aset_cd must be a 12-digit number") + if rght_tp not in ["000", "001", "002", "003", "004", "005", "006", "007"]: + raise ValueError("Invalid rght_tp value") + if not lpcd.isdigit() or len(lpcd) != 12: + raise ValueError("lpcd must be a 12-digit number") + if trde_end_elwskip not in ["0", "1"]: + raise ValueError("trde_end_elwskip must be '0' (포함) or '1' (제외)") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30004", + } + + data = { + "isscomp_cd": isscomp_cd, + "bsis_aset_cd": bsis_aset_cd, + "rght_tp": rght_tp, + "lpcd": lpcd, + "trde_end_elwskip": trde_end_elwskip, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_condition_search_request_ka30005( + self, + isscomp_cd: str, + bsis_aset_cd: str, + rght_tp: str, + lpcd: str, + sort_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 조건검색 정보를 조회합니다. + + Args: + isscomp_cd (str): 발행사코드 (12자리) + - 000000000000: 전체 + - 000000000003: 한국투자증권 + - 000000000005: 미래대우 + - 000000000006: 신영 + - 000000000012: NK투자증권 + - 000000000017: KB증권 + bsis_aset_cd (str): 기초자산코드 + - 000000000000: 전체 + - 201: KOSPI200 + - 150: KOSDAQ150 + - 005930: 삼성전자 + - 030200: KT + rght_tp (str): 권리구분 + - 0: 전체 + - 1: 콜 + - 2: 풋 + - 3: DC + - 4: DP + - 5: EX + - 6: 조기종료콜 + - 7: 조기종료풋 + lpcd (str): LP코드 (12자리) + - 000000000000: 전체 + - 000000000003: 한국투자증권 + - 000000000005: 미래대우 + - 000000000006: 신영 + - 000000000012: NK투자증권 + - 000000000017: KB증권 + sort_tp (str): 정렬구분 + - 0: 정렬없음 + - 1: 상승율순 + - 2: 상승폭순 + - 3: 하락율순 + - 4: 하락폭순 + - 5: 거래량순 + - 6: 거래대금순 + - 7: 잔존일순 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 조건검색 데이터 + { + "elwcnd_qry": [ # ELW조건검색 + { + "stk_cd": str, # 종목코드 + "isscomp_nm": str, # 발행사명 + "sqnc": str, # 회차 + "base_aset_nm": str, # 기초자산명 + "rght_tp": str, # 권리구분 + "expr_dt": str, # 만기일 + "cur_prc": str, # 현재가 + "pre_tp": str, # 대비구분 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락율 + "trde_qty": str, # 거래량 + "trde_qty_pre": str, # 거래량대비 + "trde_prica": str, # 거래대금 + "pred_trde_qty": str, # 전일거래량 + "sel_bid": str, # 매도호가 + "buy_bid": str, # 매수호가 + "prty": str, # 패리티 + "gear_rt": str, # 기어링비율 + "pl_qutr_rt": str, # 손익분기율 + "cfp": str, # 자본지지점 + "theory_pric": str, # 이론가 + "innr_vltl": str, # 내재변동성 + "delta": str, # 델타 + "lvrg": str, # 레버리지 + "exec_pric": str, # 행사가격 + "cnvt_rt": str, # 전환비율 + "lpposs_rt": str, # LP보유비율 + "pl_qutr_pt": str, # 손익분기점 + "fin_trde_dt": str, # 최종거래일 + "flo_dt": str, # 상장일 + "lpinitlast_suply_dt": str, # LP초종공급일 + "stk_nm": str, # 종목명 + "srvive_dys": str, # 잔존일수 + "dispty_rt": str, # 괴리율 + "lpmmcm_nm": str, # LP회원사명 + "lpmmcm_nm_1": str, # LP회원사명1 + "lpmmcm_nm_2": str, # LP회원사명2 + "xraymont_cntr_qty_arng_trde_tp": str, # Xray순간체결량정리매매구분 + "xraymont_cntr_qty_profa_100tp": str, # Xray순간체결량증거금100구분 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_condition_search_request_ka30005( + ... isscomp_cd="000000000017", # KB증권 + ... bsis_aset_cd="201", # KOSPI200 + ... rght_tp="1", # 콜 + ... lpcd="000000000000", # 전체 + ... sort_tp="0" # 정렬없음 + ... ) + """ + # 파라미터 유효성 검증 + if not isscomp_cd.isdigit() or len(isscomp_cd) != 12: + raise ValueError("isscomp_cd must be a 12-digit number") + if not bsis_aset_cd.isdigit(): + raise ValueError("bsis_aset_cd must be a number") + if bsis_aset_cd == "000000000000" and len(bsis_aset_cd) != 12: + raise ValueError("bsis_aset_cd must be 12 digits when '000000000000'") + if rght_tp not in ["0", "1", "2", "3", "4", "5", "6", "7"]: + raise ValueError("Invalid rght_tp value") + if not lpcd.isdigit() or len(lpcd) != 12: + raise ValueError("lpcd must be a 12-digit number") + if sort_tp not in ["0", "1", "2", "3", "4", "5", "6", "7"]: + raise ValueError("Invalid sort_tp value") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30005", + } + + data = { + "isscomp_cd": isscomp_cd, + "bsis_aset_cd": bsis_aset_cd, + "rght_tp": rght_tp, + "lpcd": lpcd, + "sort_tp": sort_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_price_change_rate_ranking_request_ka30009( + self, + sort_tp: str, + rght_tp: str, + trde_end_skip: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 등락율 순위 정보를 조회합니다. + + Args: + sort_tp (str): 정렬구분 + - 1: 상승률 + - 2: 상승폭 + - 3: 하락률 + - 4: 하락폭 + rght_tp (str): 권리구분 (3자리) + - 000: 전체 + - 001: 콜 + - 002: 풋 + - 003: DC + - 004: DP + - 006: 조기종료콜 + - 007: 조기종료풋 + trde_end_skip (str): 거래종료제외 + - 0: 거래종료포함 + - 1: 거래종료제외 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 등락율 순위 데이터 + { + "elwflu_rt_rank": [ # ELW등락율순위 + { + "rank": str, # 순위 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pre_sig": str, # 대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "sel_req": str, # 매도잔량 + "buy_req": str, # 매수잔량 + "trde_qty": str, # 거래량 + "trde_prica": str, # 거래대금 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_price_change_rate_ranking_request_ka30009( + ... sort_tp="1", # 상승률 + ... rght_tp="000", # 전체 + ... trde_end_skip="0" # 거래종료포함 + ... ) + """ + # 파라미터 유효성 검증 + if sort_tp not in ["1", "2", "3", "4"]: + raise ValueError("sort_tp must be one of: '1' (상승률), '2' (상승폭), '3' (하락률), '4' (하락폭)") + if rght_tp not in ["000", "001", "002", "003", "004", "006", "007"]: + raise ValueError("Invalid rght_tp value") + if trde_end_skip not in ["0", "1"]: + raise ValueError("trde_end_skip must be '0' (포함) or '1' (제외)") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30009", + } + + data = { + "sort_tp": sort_tp, + "rght_tp": rght_tp, + "trde_end_skip": trde_end_skip, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_order_volume_ranking_request_ka30010( + self, + sort_tp: str, + rght_tp: str, + trde_end_skip: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 잔량 순위 정보를 조회합니다. + + Args: + sort_tp (str): 정렬구분 + - 1: 순매수잔량상위 + - 2: 순매도잔량상위 + rght_tp (str): 권리구분 (3자리) + - 000: 전체 + - 001: 콜 + - 002: 풋 + - 003: DC + - 004: DP + - 006: 조기종료콜 + - 007: 조기종료풋 + trde_end_skip (str): 거래종료제외 + - 0: 거래종료포함 + - 1: 거래종료제외 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 잔량 순위 데이터 + { + "elwreq_rank": [ # ELW잔량순위 + { + "stk_cd": str, # 종목코드 + "rank": str, # 순위 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pre_sig": str, # 대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "trde_qty": str, # 거래량 + "sel_req": str, # 매도잔량 + "buy_req": str, # 매수잔량 + "netprps_req": str, # 순매수잔량 + "trde_prica": str, # 거래대금 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_order_volume_ranking_request_ka30010( + ... sort_tp="1", # 순매수잔량상위 + ... rght_tp="000", # 전체 + ... trde_end_skip="0" # 거래종료포함 + ... ) + """ + # 파라미터 유효성 검증 + if sort_tp not in ["1", "2"]: + raise ValueError("sort_tp must be '1' (순매수잔량상위) or '2' (순매도잔량상위)") + if rght_tp not in ["000", "001", "002", "003", "004", "006", "007"]: + raise ValueError("Invalid rght_tp value") + if trde_end_skip not in ["0", "1"]: + raise ValueError("trde_end_skip must be '0' (포함) or '1' (제외)") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30010", + } + + data = { + "sort_tp": sort_tp, + "rght_tp": rght_tp, + "trde_end_skip": trde_end_skip, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_proximity_rate_request_ka30011( + self, + stk_cd: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 근접율 정보를 조회합니다. + + Args: + stk_cd (str): 종목코드 (6자리) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 근접율 데이터 + { + "elwalacc_rt": [ # ELW근접율 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pre_sig": str, # 대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락율 + "acc_trde_qty": str, # 누적거래량 + "alacc_rt": str, # 근접율 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_proximity_rate_request_ka30011( + ... stk_cd="57JBHH" # 종목코드 + ... ) + """ + # 파라미터 유효성 검증 + if not stk_cd or len(stk_cd) != 6: + raise ValueError("stk_cd must be a 6-character string") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30011", + } + + data = { + "stk_cd": stk_cd, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def elw_detailed_stock_info_request_ka30012( + self, + stk_cd: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """ELW 종목 상세 정보를 조회합니다. + + Args: + stk_cd (str): 종목코드 (6자리) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: ELW 종목 상세 데이터 + { + "aset_cd": str, # 자산코드 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락율 + "lpmmcm_nm": str, # LP회원사명 + "lpmmcm_nm_1": str, # LP회원사명1 + "lpmmcm_nm_2": str, # LP회원사명2 + "elwrght_cntn": str, # ELW권리내용 + "elwexpr_evlt_pric": str, # ELW만기평가가격 + "elwtheory_pric": str, # ELW이론가 + "dispty_rt": str, # 괴리율 + "elwinnr_vltl": str, # ELW내재변동성 + "exp_rght_pric": str, # 예상권리가 + "elwpl_qutr_rt": str, # ELW손익분기율 + "elwexec_pric": str, # ELW행사가 + "elwcnvt_rt": str, # ELW전환비율 + "elwcmpn_rt": str, # ELW보상율 + "elwpric_rising_part_rt": str, # ELW가격상승참여율 + "elwrght_type": str, # ELW권리유형 + "elwsrvive_dys": str, # ELW잔존일수 + "stkcnt": str, # 주식수 + "elwlpord_pos": str, # ELWLP주문가능 + "lpposs_rt": str, # LP보유비율 + "lprmnd_qty": str, # LP보유수량 + "elwspread": str, # ELW스프레드 + "elwprty": str, # ELW패리티 + "elwgear": str, # ELW기어링 + "elwflo_dt": str, # ELW상장일 + "elwfin_trde_dt": str, # ELW최종거래일 + "expr_dt": str, # 만기일 + "exec_dt": str, # 행사일 + "lpsuply_end_dt": str, # LP공급종료일 + "elwpay_dt": str, # ELW지급일 + "elwinvt_ix_comput": str, # ELW투자지표산출 + "elwpay_agnt": str, # ELW지급대리인 + "elwappr_way": str, # ELW결재방법 + "elwrght_exec_way": str, # ELW권리행사방식 + "elwpblicte_orgn": str, # ELW발행기관 + "dcsn_pay_amt": str, # 확정지급액 + "kobarr": str, # KO베리어 + "iv": str, # IV + "clsprd_end_elwocr": str, # 종기종료ELW발생 + "bsis_aset_1": str, # 기초자산1 + "bsis_aset_comp_rt_1": str, # 기초자산구성비율1 + "bsis_aset_2": str, # 기초자산2 + "bsis_aset_comp_rt_2": str, # 기초자산구성비율2 + "bsis_aset_3": str, # 기초자산3 + "bsis_aset_comp_rt_3": str, # 기초자산구성비율3 + "bsis_aset_4": str, # 기초자산4 + "bsis_aset_comp_rt_4": str, # 기초자산구성비율4 + "bsis_aset_5": str, # 기초자산5 + "bsis_aset_comp_rt_5": str, # 기초자산구성비율5 + "fr_dt": str, # 평가시작일자 + "to_dt": str, # 평가종료일자 + "fr_tm": str, # 평가시작시간 + "evlt_end_tm": str, # 평가종료시간 + "evlt_pric": str, # 평가가격 + "evlt_fnsh_yn": str, # 평가완료여부 + "all_hgst_pric": str, # 전체최고가 + "all_lwst_pric": str, # 전체최저가 + "imaf_hgst_pric": str, # 직후최고가 + "imaf_lwst_pric": str, # 직후최저가 + "sndhalf_mrkt_hgst_pric": str, # 후반장최고가 + "sndhalf_mrkt_lwst_pric": str, # 후반장최저가 + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Raises: + ValueError: 필수 파라미터가 누락되었거나 유효하지 않은 경우 + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.elw.elw_detailed_stock_info_request_ka30012( + ... stk_cd="57JBHH" # 종목코드 + ... ) + """ + # 파라미터 유효성 검증 + if not stk_cd or len(stk_cd) != 6: + raise ValueError("stk_cd must be a 6-character string") + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka30012", + } + + data = { + "stk_cd": stk_cd, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/etf.py b/kiwoom_rest_api/koreanstock/etf.py new file mode 100644 index 0000000..17be8ac --- /dev/null +++ b/kiwoom_rest_api/koreanstock/etf.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/foreign_institution.py b/kiwoom_rest_api/koreanstock/foreign_institution.py new file mode 100644 index 0000000..9d56447 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/foreign_institution.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/investor.py b/kiwoom_rest_api/koreanstock/investor.py new file mode 100644 index 0000000..4c81879 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/investor.py @@ -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, + ) diff --git a/kiwoom_rest_api/koreanstock/market_condition.py b/kiwoom_rest_api/koreanstock/market_condition.py new file mode 100644 index 0000000..bae1ce8 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/market_condition.py @@ -0,0 +1,1111 @@ +from kiwoom_rest_api.core.base_api import KiwoomBaseAPI +from typing import Union, Dict, Any, Awaitable + +class MarketCondition(KiwoomBaseAPI): + """한국 주식 시장 조건 관련 API를 제공하는 클래스""" + + def __init__( + self, + base_url: str = None, + token_manager=None, + use_async: bool = False, + resource_url: str = "/api/dostk/mrkcond" + ): + """ + MarketCondition 클래스 초기화 + + 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_quote_request_ka10004( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """주식호가요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "bid_req_base_tm": "162000", + "sel_10th_pre_req_pre": "0", + "sel_10th_pre_req": "0", + "sel_10th_pre_bid": "0", + ... + "ovt_buy_req_pre": "0", + "return_code": 0, + "return_msg": "정상적으로 처리되었습니다" + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10004" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def stock_daily_weekly_monthly_time_request_ka10005( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """주식일주월시분요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "stk_ddwkmm": [ + { + "date": "20241028", + "open_pric": "95400", + "high_pric": "95400", + "low_pric": "95400", + "close_pric": "95400", + "pre": "0", + "flu_rt": "0.00", + "trde_qty": "0", + "trde_prica": "0", + "for_poss": "+26.07", + "for_wght": "+26.07", + "for_netprps": "0", + "orgn_netprps": "", + "ind_netprps": "", + "crd_remn_rt": "", + "frgn": "", + "prm": "" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10005" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def stock_minute_time_request_ka10006( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """주식시분요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "date": "20241105", + "open_pric": "0", + "high_pric": "0", + "low_pric": "0", + "close_pric": "135300", + "pre": "0", + "flu_rt": "0.00", + "trde_qty": "0", + "trde_prica": "0", + "cntr_str": "0.00", + "return_code": 0, + "return_msg": "정상적으로 처리되었습니다" + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10006" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def market_price_table_info_request_ka10007( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """시세표성정보요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "stk_nm": "삼성전자", + "stk_cd": "005930", + "date": "20241105", + "tm": "104000", + "pred_close_pric": "135300", + "pred_trde_qty": "88862", + "upl_pric": "+175800", + "lst_pric": "-94800", + "pred_trde_prica": "11963", + "flo_stkcnt": "25527", + "cur_prc": "135300", + "smbol": "3", + "flu_rt": "0.00", + "pred_rt": "0.00", + "open_pric": "0", + "high_pric": "0", + "low_pric": "0", + "cntr_qty": "", + "trde_qty": "0", + "trde_prica": "0", + "exp_cntr_pric": "-0", + "exp_cntr_qty": "0", + "exp_sel_pri_bid": "0", + "exp_buy_pri_bid": "0", + "trde_strt_dt": "00000000", + "exec_pric": "0", + "hgst_pric": "", + "lwst_pric": "", + "hgst_pric_dt": "", + "lwst_pric_dt": "", + "sel_1bid": "0", + "sel_2bid": "0", + ... + "buy_10bid_req": "0", + "tot_buy_req": "0", + "tot_sel_req": "0", + "tot_buy_cnt": "", + "tot_sel_cnt": "0", + "return_code": 0, + "return_msg": "정상적으로 처리되었습니다" + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10007" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def rights_issue_overall_price_request_ka10011( + self, + rights_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """신주인수권전체시세요청 + + Args: + rights_type (str): 신주인수권구분 (00:전체, 05:신주인수권증권, 07:신주인수권증서) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "newstk_recvrht_mrpr": [ + { + "stk_cd": "J0036221D", + "stk_nm": "KG모빌리티 122WR", + "cur_prc": "988", + "pred_pre_sig": "3", + "pred_pre": "0", + "flu_rt": "0.00", + "fpr_sel_bid": "-0", + "fpr_buy_bid": "-0", + "acc_trde_qty": "0", + "open_pric": "-0", + "high_pric": "-0", + "low_pric": "-0" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10011" + } + data = { + "newstk_recvrht_tp": rights_type + } + return self._execute_request("POST", json=data, headers=headers) + + def daily_institutional_trading_items_request_ka10044( + self, + start_date: str, + end_date: str, + trade_type: str, + market_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """일별기관매매종목요청 + + Args: + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + trade_type (str): 매매구분 (1:순매도, 2:순매수) + market_type (str): 시장구분 (001:코스피, 101:코스닥) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "daly_orgn_trde_stk": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "netprps_qty": "-0", + "netprps_amt": "-1", + "prsm_avg_pric": "140000", + "cur_prc": "-95100", + "avg_pric_pre": "--44900", + "pre_rt": "-32.07" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10044" + } + data = { + "strt_dt": start_date, + "end_dt": end_date, + "trde_tp": trade_type, + "mrkt_tp": market_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def stockwise_institutional_trading_trend_request_ka10045( + self, + stock_code: str, + start_date: str, + end_date: str, + org_institution_price_type: str, + foreign_institution_price_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목별기관매매추이요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + org_institution_price_type (str): 기관추정단가구분 (1:매수단가, 2:매도단가) + foreign_institution_price_type (str): 외인추정단가구분 (1:매수단가, 2:매도단가) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "orgn_prsm_avg_pric": "117052", + "for_prsm_avg_pric": "0", + "stk_orgn_trde_trnsn": [ + { + "dt": "20241107", + "close_pric": "133600", + "pre_sig": "0", + "pred_pre": "0", + "flu_rt": "0.00", + "trde_qty": "0", + "orgn_dt_acc": "158", + "orgn_daly_nettrde_qty": "0", + "for_dt_acc": "28315", + "for_daly_nettrde_qty": "0", + "limit_exh_rt": "+26.14" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10045" + } + data = { + "stk_cd": stock_code, + "strt_dt": start_date, + "end_dt": end_date, + "orgn_prsm_unp_tp": org_institution_price_type, + "for_prsm_unp_tp": foreign_institution_price_type + } + return self._execute_request("POST", json=data, headers=headers) + + def execution_strength_by_hour_request_ka10046( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """체결강도추이시간별요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "cntr_str_tm": [ + { + "cntr_tm": "163713", + "cur_prc": "+156600", + "pred_pre": "+34900", + "pred_pre_sig": "2", + "flu_rt": "+28.68", + "trde_qty": "-1", + "acc_trde_prica": "14449", + "acc_trde_qty": "113636", + "cntr_str": "172.01", + "cntr_str_5min": "172.01", + "cntr_str_20min": "172.01", + "cntr_str_60min": "170.67", + "stex_tp": "KRX" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10046" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def execution_strength_by_day_request_ka10047( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """체결강도추이일별요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "cntr_str_daly": [ + { + "dt": "20241128", + "cur_prc": "+219000", + "pred_pre": "+14000", + "pred_pre_sig": "2", + "flu_rt": "+6.83", + "trde_qty": "", + "acc_trde_prica": "2", + "acc_trde_qty": "8", + "cntr_str": "0.00", + "cntr_str_5min": "201.54", + "cntr_str_20min": "139.37", + "cntr_str_60min": "172.06" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10047" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def intraday_investor_trading_request_ka10063( + self, + market_type: str, + amount_quantity_type: str, + investor_type: str, + foreign_all: str, + simultaneous_net_buy_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """장중투자자별매매요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + amount_quantity_type (str): 금액수량구분 (1:금액, 2:수량) + investor_type (str): 투자자별 (6:외국인, 7:기관계, 1:투신, 0:보험, 2:은행, 3:연기금, 4:국가, 5:기타법인) + foreign_all (str): 외국계전체 (1:체크, 0:미체크) + simultaneous_net_buy_type (str): 동시순매수구분 (1:체크, 0:미체크) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "opmr_invsr_trde": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "64", + "pre_sig": "3", + "pred_pre": "0", + "flu_rt": "0.00", + "acc_trde_qty": "1", + "netprps_qty": "+1083000", + "prev_pot_netprps_qty": "+1083000", + "netprps_irds": "0", + "buy_qty": "+1113000", + "buy_qty_irds": "0", + "sell_qty": "--30000", + "sell_qty_irds": "0" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10063" + } + data = { + "mrkt_tp": market_type, + "amt_qty_tp": amount_quantity_type, + "invsr": investor_type, + "frgn_all": foreign_all, + "smtm_netprps_tp": simultaneous_net_buy_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def post_market_investor_trading_request_ka10066( + self, + market_type: str, + amount_quantity_type: str, + trade_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """장마감후투자자별매매요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + amount_quantity_type (str): 금액수량구분 (1:금액, 2:수량) + trade_type (str): 매매구분 (0:순매수, 1:매수, 2:매도) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "opaf_invsr_trde": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "-7410", + "pre_sig": "5", + "pred_pre": "-50", + "flu_rt": "-0.67", + "trde_qty": "8", + "ind_invsr": "0", + "frgnr_invsr": "0", + "orgn": "0", + "fnnc_invt": "0", + "insrnc": "0", + "invtrt": "0", + "etc_fnnc": "0", + "bank": "0", + "penfnd_etc": "0", + "samo_fund": "0", + "natn": "0", + "etc_corp": "0" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10066" + } + data = { + "mrkt_tp": market_type, + "amt_qty_tp": amount_quantity_type, + "trde_tp": trade_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def brokerwise_stock_trading_trend_request_ka10078( + self, + member_company_code: str, + stock_code: str, + start_date: str, + end_date: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """증권사별종목매매동향요청 + + Args: + member_company_code (str): 회원사코드 (회원사 코드는 ka10102 조회) + stock_code (str): 종목코드 (거래소별 종목코드, 예: KRX:039490, NXT:039490_NX, SOR:039490_AL) + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "sec_stk_trde_trend": [ + { + "dt": "20241107", + "cur_prc": "10050", + "pre_sig": "0", + "pred_pre": "0", + "flu_rt": "0.00", + "acc_trde_qty": "0", + "netprps_qty": "0", + "buy_qty": "0", + "sell_qty": "0" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10078" + } + data = { + "mmcm_cd": member_company_code, + "stk_cd": stock_code, + "strt_dt": start_date, + "end_dt": end_date + } + return self._execute_request("POST", json=data, headers=headers) + + def daily_stock_price_request_ka10086( + self, + stock_code: str, + query_date: str, + indicator_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """일별주가요청 + + Args: + stock_code (str): 종목코드 (거래소별 종목코드, 예: KRX:039490, NXT:039490_NX, SOR:039490_AL) + query_date (str): 조회일자 (YYYYMMDD) + indicator_type (str): 표시구분 (0:수량, 1:금액(백만원)) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "daly_stkpc": [ + { + "date": "20241125", + "open_pric": "+78800", + "high_pric": "+101100", + "low_pric": "-54500", + "close_pric": "-55000", + "pred_rt": "-22800", + "flu_rt": "-29.31", + "trde_qty": "20278", + "amt_mn": "1179", + "crd_rt": "0.00", + "ind": "--714", + "orgn": "+693", + "for_qty": "--266783", + "frgn": "0", + "prm": "0", + "for_rt": "+51.56", + "for_poss": "+51.56", + "for_wght": "+51.56", + "for_netprps": "--266783", + "orgn_netprps": "+693", + "ind_netprps": "--714", + "crd_remn_rt": "0.00" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10086" + } + data = { + "stk_cd": stock_code, + "qry_dt": query_date, + "indc_tp": indicator_type + } + return self._execute_request("POST", json=data, headers=headers) + + def after_hours_single_price_request_ka10087( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """시간외단일가요청 + + Args: + stock_code (str): 종목코드 (예: "005930") + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "bid_req_base_tm": "164000", + "ovt_sigpric_sel_bid_jub_pre_5": "0", + "ovt_sigpric_sel_bid_jub_pre_4": "0", + "ovt_sigpric_sel_bid_jub_pre_3": "0", + "ovt_sigpric_sel_bid_jub_pre_2": "0", + "ovt_sigpric_sel_bid_jub_pre_1": "0", + "ovt_sigpric_sel_bid_qty_5": "0", + "ovt_sigpric_sel_bid_qty_4": "0", + "ovt_sigpric_sel_bid_qty_3": "0", + "ovt_sigpric_sel_bid_qty_2": "0", + "ovt_sigpric_sel_bid_qty_1": "0", + "ovt_sigpric_sel_bid_5": "-0", + "ovt_sigpric_sel_bid_4": "-0", + "ovt_sigpric_sel_bid_3": "-0", + "ovt_sigpric_sel_bid_2": "-0", + "ovt_sigpric_sel_bid_1": "-0", + "ovt_sigpric_buy_bid_1": "-0", + "ovt_sigpric_buy_bid_2": "-0", + "ovt_sigpric_buy_bid_3": "-0", + "ovt_sigpric_buy_bid_4": "-0", + "ovt_sigpric_buy_bid_5": "-0", + "ovt_sigpric_buy_bid_qty_1": "0", + "ovt_sigpric_buy_bid_qty_2": "0", + "ovt_sigpric_buy_bid_qty_3": "0", + "ovt_sigpric_buy_bid_qty_4": "0", + "ovt_sigpric_buy_bid_qty_5": "0", + "ovt_sigpric_buy_bid_jub_pre_1": "0", + "ovt_sigpric_buy_bid_jub_pre_2": "0", + "ovt_sigpric_buy_bid_jub_pre_3": "0", + "ovt_sigpric_buy_bid_jub_pre_4": "0", + "ovt_sigpric_buy_bid_jub_pre_5": "0", + "ovt_sigpric_sel_bid_tot_req": "0", + "ovt_sigpric_buy_bid_tot_req": "0", + "sel_bid_tot_req_jub_pre": "0", + "sel_bid_tot_req": "24028", + "buy_bid_tot_req": "26579", + "buy_bid_tot_req_jub_pre": "0", + "ovt_sel_bid_tot_req_jub_pre": "0", + "ovt_sel_bid_tot_req": "0", + "ovt_buy_bid_tot_req": "11", + "ovt_buy_bid_tot_req_jub_pre": "0", + "ovt_sigpric_cur_prc": "156600", + "ovt_sigpric_pred_pre_sig": "0", + "ovt_sigpric_pred_pre": "0", + "ovt_sigpric_flu_rt": "0.00", + "ovt_sigpric_acc_trde_qty": "0" + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10087" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def program_trading_trend_by_time_request_ka90005( + self, + date: str, + amount_quantity_type: str, + market_type: str, + minute_tick_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """프로그램매매추이요청 (시간대별) + + Args: + date (str): 날짜 (YYYYMMDD) + amount_quantity_type (str): 금액수량구분 (1:금액(백만원), 2:수량(천주)) + market_type (str): 시장구분 (코스피- 거래소구분값 1일경우:P00101, 2일경우:P001_NX01, 3일경우:P001_AL01, 코스닥- 거래소구분값 1일경우:P10102, 2일경우:P101_NX02, 3일경우:P001_AL02) + minute_tick_type (str): 분틱구분 (0:틱, 1:분) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "prm_trde_trnsn": [ + { + "cntr_tm": "170500", + "dfrt_trde_sel": "0", + "dfrt_trde_buy": "0", + "dfrt_trde_netprps": "0", + "ndiffpro_trde_sel": "1", + "ndiffpro_trde_buy": "17", + "ndiffpro_trde_netprps": "+17", + "dfrt_trde_sell_qty": "0", + "dfrt_trde_buy_qty": "0", + "dfrt_trde_netprps_qty": "0", + "ndiffpro_trde_sell_qty": "0", + "ndiffpro_trde_buy_qty": "0", + "ndiffpro_trde_netprps_qty": "+0", + "all_sel": "1", + "all_buy": "17", + "all_netprps": "+17", + "kospi200": "+47839", + "basis": "-146.59" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90005" + } + data = { + "date": date, + "amt_qty_tp": amount_quantity_type, + "mrkt_tp": market_type, + "min_tic_tp": minute_tick_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def program_trading_arbitrage_balance_trend_request_ka90006( + self, + date: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """프로그램매매차익잔고추이요청 + + Args: + date (str): 날짜 (YYYYMMDD) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "prm_trde_dfrt_remn_trnsn": [ + { + "dt": "20241125", + "buy_dfrt_trde_qty": "0", + "buy_dfrt_trde_amt": "0", + "buy_dfrt_trde_irds_amt": "0", + "sel_dfrt_trde_qty": "0", + "sel_dfrt_trde_amt": "0", + "sel_dfrt_trde_irds_amt": "0" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90006" + } + data = { + "date": date, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def cumulative_program_trading_trend_request_ka90007( + self, + date: str, + amount_quantity_type: str, + market_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """프로그램매매누적추이요청 + + Args: + date (str): 날짜 (YYYYMMDD, 종료일기준 1년간 데이터만 조회가능) + amount_quantity_type (str): 금액수량구분 (1:금액, 2:수량) + market_type (str): 시장구분 (0:코스피, 1:코스닥) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "prm_trde_acc_trnsn": [ + { + "dt": "20241125", + "kospi200": "0.00", + "basis": "0.00", + "dfrt_trde_tdy": "0", + "dfrt_trde_acc": "+353665", + "ndiffpro_trde_tdy": "0", + "ndiffpro_trde_acc": "+671219", + "all_tdy": "0", + "all_acc": "+1024884" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90007" + } + data = { + "date": date, + "amt_qty_tp": amount_quantity_type, + "mrkt_tp": market_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def stockwise_program_trading_by_hour_request_ka90008( + self, + amount_quantity_type: str, + stock_code: str, + date: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목시간별프로그램매매추이요청 + + Args: + amount_quantity_type (str): 금액수량구분 (1:금액, 2:수량) + stock_code (str): 종목코드 (거래소별 종목코드, 예: KRX:039490, NXT:039490_NX, SOR:039490_AL) + date (str): 날짜 (YYYYMMDD) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "stk_tm_prm_trde_trnsn": [ + { + "tm": "153029", + "cur_prc": "+245500", + "pre_sig": "2", + "pred_pre": "+40000", + "flu_rt": "+19.46", + "trde_qty": "104006", + "prm_sell_amt": "14245", + "prm_buy_amt": "10773", + "prm_netprps_amt": "--3472", + "prm_netprps_amt_irds": "+771", + "prm_sell_qty": "58173", + "prm_buy_qty": "43933", + "prm_netprps_qty": "--14240", + "prm_netprps_qty_irds": "+3142", + "base_pric_tm": "", + "dbrt_trde_rpy_sum": "", + "remn_rcvord_sum": "", + "stex_tp": "KRX" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90008" + } + data = { + "amt_qty_tp": amount_quantity_type, + "stk_cd": stock_code, + "date": date + } + return self._execute_request("POST", json=data, headers=headers) + + def program_trading_trend_by_date_request_ka90010( + self, + date: str, + amount_quantity_type: str, + market_type: str, + minute_tick_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """프로그램매매추이요청 (일자별) + + Args: + date (str): 날짜 (YYYYMMDD) + amount_quantity_type (str): 금액수량구분 (1:금액(백만원), 2:수량(천주)) + market_type (str): 시장구분 (코스피- 거래소구분값 1일경우:P00101, 2일경우:P001_NX01, 3일경우:P001_AL01, 코스닥- 거래소구분값 1일경우:P10102, 2일경우:P101_NX02, 3일경우:P001_AL02) + minute_tick_type (str): 분틱구분 (0:틱, 1:분) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "prm_trde_trnsn": [ + { + "cntr_tm": "20241125000000", + "dfrt_trde_sel": "0", + "dfrt_trde_buy": "0", + "dfrt_trde_netprps": "0", + "ndiffpro_trde_sel": "0", + "ndiffpro_trde_buy": "0", + "ndiffpro_trde_netprps": "0", + "dfrt_trde_sell_qty": "0", + "dfrt_trde_buy_qty": "0", + "dfrt_trde_netprps_qty": "0", + "ndiffpro_trde_sell_qty": "0", + "ndiffpro_trde_buy_qty": "0", + "ndiffpro_trde_netprps_qty": "0", + "all_sel": "0", + "all_buy": "0", + "all_netprps": "0", + "kospi200": "0.00", + "basis": "" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90010" + } + data = { + "date": date, + "amt_qty_tp": amount_quantity_type, + "mrkt_tp": market_type, + "min_tic_tp": minute_tick_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def stockwise_program_trading_by_day_request_ka90013( + self, + stock_code: str, + amount_quantity_type: str = "", + date: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목일별프로그램매매추이요청 + + Args: + stock_code (str): 종목코드 (거래소별 종목코드, 예: KRX:039490, NXT:039490_NX, SOR:039490_AL) + amount_quantity_type (str, optional): 금액수량구분 (1:금액, 2:수량). Defaults to "". + date (str, optional): 날짜 (YYYYMMDD). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "stk_daly_prm_trde_trnsn": [ + { + "dt": "20241125", + "cur_prc": "+267000", + "pre_sig": "2", + "pred_pre": "+60000", + "flu_rt": "+28.99", + "trde_qty": "3", + "prm_sell_amt": "0", + "prm_buy_amt": "0", + "prm_netprps_amt": "0", + "prm_netprps_amt_irds": "0", + "prm_sell_qty": "0", + "prm_buy_qty": "0", + "prm_netprps_qty": "0", + "prm_netprps_qty_irds": "0", + "base_pric_tm": "", + "dbrt_trde_rpy_sum": "", + "remn_rcvord_sum": "", + "stex_tp": "통합" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90013" + } + data = { + "amt_qty_tp": amount_quantity_type, + "stk_cd": stock_code, + "date": date + } + return self._execute_request("POST", json=data, headers=headers) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/order.py b/kiwoom_rest_api/koreanstock/order.py new file mode 100644 index 0000000..711f932 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/order.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/rank_info.py b/kiwoom_rest_api/koreanstock/rank_info.py new file mode 100644 index 0000000..db8c4f4 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/rank_info.py @@ -0,0 +1,2311 @@ +from kiwoom_rest_api.core.base_api import KiwoomBaseAPI +from typing import Union, Dict, Any, Awaitable + +class RankInfo(KiwoomBaseAPI): + """한국 주식 랭크 정보 API를 제공하는 클래스""" + + def __init__( + self, + base_url: str = None, + token_manager=None, + use_async: bool = False, + resource_url: str = "/api/dostk/rkinfo" + ): + """ + RankInfo 클래스 초기화 + + 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 top_order_book_volume_request_ka10020( + self, + mrkt_tp: str, + sort_tp: str, + trde_qty_tp: str, + stk_cnd: str, + crd_cnd: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """호가잔량상위를 조회합니다. + + Args: + mrkt_tp (str): 시장구분 (001:코스피, 101:코스닥) + sort_tp (str): 정렬구분 + - 1: 순매수잔량순 + - 2: 순매도잔량순 + - 3: 매수비율순 + - 4: 매도비율순 + trde_qty_tp (str): 거래량구분 + - 0000: 장시작전(0주이상) + - 0010: 만주이상 + - 0050: 5만주이상 + - 00100: 10만주이상 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 5: 증100제외 + - 6: 증100만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + crd_cnd (str): 신용조건 + - 0: 전체조회 + - 1: 신용융자A군 + - 2: 신용융자B군 + - 3: 신용융자C군 + - 4: 신용융자D군 + - 9: 신용융자전체 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 호가잔량상위 데이터 + { + "bid_req_upper": [ # 호가잔량상위 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "trde_qty": str, # 거래량 + "tot_sel_req": str, # 총매도잔량 + "tot_buy_req": str, # 총매수잔량 + "netprps_req": str, # 순매수잔량 + "buy_rt": str, # 매수비율 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_order_book_volume_request_ka10020( + ... mrkt_tp="001", + ... sort_tp="1", + ... trde_qty_tp="0000", + ... stk_cnd="0", + ... crd_cnd="0", + ... stex_tp="1" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10020", + } + + data = { + "mrkt_tp": mrkt_tp, + "sort_tp": sort_tp, + "trde_qty_tp": trde_qty_tp, + "stk_cnd": stk_cnd, + "crd_cnd": crd_cnd, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def sudden_increase_order_book_volume_request_ka10021( + self, + mrkt_tp: str, + trde_tp: str, + sort_tp: str, + tm_tp: str, + trde_qty_tp: str, + stk_cnd: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """호가잔량급증을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 (001:코스피, 101:코스닥) + trde_tp (str): 매매구분 + - 1: 매수잔량 + - 2: 매도잔량 + sort_tp (str): 정렬구분 + - 1: 급증량 + - 2: 급증률 + tm_tp (str): 시간구분 (분 입력) + trde_qty_tp (str): 거래량구분 + - 1: 천주이상 + - 5: 5천주이상 + - 10: 만주이상 + - 50: 5만주이상 + - 100: 10만주이상 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 5: 증100제외 + - 6: 증100만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 호가잔량급증 데이터 + { + "bid_req_sdnin": [ # 호가잔량급증 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "int": str, # 기준률 + "now": str, # 현재 + "sdnin_qty": str, # 급증수량 + "sdnin_rt": str, # 급증률 + "tot_buy_qty": str, # 총매수량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.sudden_increase_order_book_volume_request_ka10021( + ... mrkt_tp="001", + ... trde_tp="1", + ... sort_tp="1", + ... tm_tp="30", + ... trde_qty_tp="1", + ... stk_cnd="0", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10021", + } + + data = { + "mrkt_tp": mrkt_tp, + "trde_tp": trde_tp, + "sort_tp": sort_tp, + "tm_tp": tm_tp, + "trde_qty_tp": trde_qty_tp, + "stk_cnd": stk_cnd, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def sudden_increase_order_ratio_request_ka10022( + self, + mrkt_tp: str, + rt_tp: str, + tm_tp: str, + trde_qty_tp: str, + stk_cnd: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """잔량율급증을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 (001:코스피, 101:코스닥) + rt_tp (str): 비율구분 + - 1: 매수/매도비율 + - 2: 매도/매수비율 + tm_tp (str): 시간구분 (분 입력) + trde_qty_tp (str): 거래량구분 + - 5: 5천주이상 + - 10: 만주이상 + - 50: 5만주이상 + - 100: 10만주이상 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 5: 증100제외 + - 6: 증100만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 잔량율급증 데이터 + { + "req_rt_sdnin": [ # 잔량율급증 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "int": str, # 기준률 + "now_rt": str, # 현재비율 + "sdnin_rt": str, # 급증률 + "tot_sel_req": str, # 총매도잔량 + "tot_buy_req": str, # 총매수잔량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.sudden_increase_order_ratio_request_ka10022( + ... mrkt_tp="001", + ... rt_tp="1", + ... tm_tp="1", + ... trde_qty_tp="5", + ... stk_cnd="0", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10022", + } + + data = { + "mrkt_tp": mrkt_tp, + "rt_tp": rt_tp, + "tm_tp": tm_tp, + "trde_qty_tp": trde_qty_tp, + "stk_cnd": stk_cnd, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def sudden_increase_trading_volume_request_ka10023( + self, + mrkt_tp: str, + sort_tp: str, + tm_tp: str, + trde_qty_tp: str, + stk_cnd: str, + pric_tp: str, + stex_tp: str, + tm: str = "", + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """거래량급증을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + sort_tp (str): 정렬구분 + - 1: 급증량 + - 2: 급증률 + - 3: 급감량 + - 4: 급감률 + tm_tp (str): 시간구분 + - 1: 분 + - 2: 전일 + trde_qty_tp (str): 거래량구분 + - 5: 5천주이상 + - 10: 만주이상 + - 50: 5만주이상 + - 100: 10만주이상 + - 200: 20만주이상 + - 300: 30만주이상 + - 500: 50만주이상 + - 1000: 백만주이상 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 3: 우선주제외 + - 11: 정리매매종목제외 + - 4: 관리종목,우선주제외 + - 5: 증100제외 + - 6: 증100만보기 + - 13: 증60만보기 + - 12: 증50만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + - 17: ETN제외 + - 14: ETF제외 + - 18: ETF+ETN제외 + - 15: 스팩제외 + - 20: ETF+ETN+스팩제외 + pric_tp (str): 가격구분 + - 0: 전체조회 + - 2: 5만원이상 + - 5: 1만원이상 + - 6: 5천원이상 + - 8: 1천원이상 + - 9: 10만원이상 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + tm (str, optional): 시간(분 입력). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 거래량급증 데이터 + { + "trde_qty_sdnin": [ # 거래량급증 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "prev_trde_qty": str, # 이전거래량 + "now_trde_qty": str, # 현재거래량 + "sdnin_qty": str, # 급증량 + "sdnin_rt": str, # 급증률 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.sudden_increase_trading_volume_request_ka10023( + ... mrkt_tp="000", + ... sort_tp="1", + ... tm_tp="2", + ... trde_qty_tp="5", + ... stk_cnd="0", + ... pric_tp="0", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10023", + } + + data = { + "mrkt_tp": mrkt_tp, + "sort_tp": sort_tp, + "tm_tp": tm_tp, + "trde_qty_tp": trde_qty_tp, + "tm": tm, + "stk_cnd": stk_cnd, + "pric_tp": pric_tp, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_day_over_day_change_rate_request_ka10027( + self, + mrkt_tp: str, + sort_tp: str, + trde_qty_cnd: str, + stk_cnd: str, + crd_cnd: str, + updown_incls: str, + pric_cnd: str, + trde_prica_cnd: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """전일대비 등락률 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + sort_tp (str): 정렬구분 + - 1: 상승률 + - 2: 상승폭 + - 3: 하락률 + - 4: 하락폭 + - 5: 보합 + trde_qty_cnd (str): 거래량조건 + - 0000: 전체조회 + - 0010: 만주이상 + - 0050: 5만주이상 + - 0100: 10만주이상 + - 0150: 15만주이상 + - 0200: 20만주이상 + - 0300: 30만주이상 + - 0500: 50만주이상 + - 1000: 백만주이상 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 4: 우선주+관리주제외 + - 3: 우선주제외 + - 5: 증100제외 + - 6: 증100만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + - 11: 정리매매종목제외 + - 12: 증50만보기 + - 13: 증60만보기 + - 14: ETF제외 + - 15: 스펙제외 + - 16: ETF+ETN제외 + crd_cnd (str): 신용조건 + - 0: 전체조회 + - 1: 신용융자A군 + - 2: 신용융자B군 + - 3: 신용융자C군 + - 4: 신용융자D군 + - 9: 신용융자전체 + updown_incls (str): 상하한포함 + - 0: 불 포함 + - 1: 포함 + pric_cnd (str): 가격조건 + - 0: 전체조회 + - 1: 1천원미만 + - 2: 1천원~2천원 + - 3: 2천원~5천원 + - 4: 5천원~1만원 + - 5: 1만원이상 + - 8: 1천원이상 + - 10: 1만원미만 + trde_prica_cnd (str): 거래대금조건 + - 0: 전체조회 + - 3: 3천만원이상 + - 5: 5천만원이상 + - 10: 1억원이상 + - 30: 3억원이상 + - 50: 5억원이상 + - 100: 10억원이상 + - 300: 30억원이상 + - 500: 50억원이상 + - 1000: 100억원이상 + - 3000: 300억원이상 + - 5000: 500억원이상 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 전일대비 등락률 상위 종목 데이터 + { + "pred_pre_flu_rt_upper": [ # 전일대비등락률상위 + { + "stk_cls": str, # 종목분류 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "sel_req": str, # 매도잔량 + "buy_req": str, # 매수잔량 + "now_trde_qty": str, # 현재거래량 + "cntr_str": str, # 체결강도 + "cnt": str, # 횟수 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_day_over_day_change_rate_request_ka10027( + ... mrkt_tp="000", + ... sort_tp="1", + ... trde_qty_cnd="0000", + ... stk_cnd="0", + ... crd_cnd="0", + ... updown_incls="1", + ... pric_cnd="0", + ... trde_prica_cnd="0", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10027", + } + + data = { + "mrkt_tp": mrkt_tp, + "sort_tp": sort_tp, + "trde_qty_cnd": trde_qty_cnd, + "stk_cnd": stk_cnd, + "crd_cnd": crd_cnd, + "updown_incls": updown_incls, + "pric_cnd": pric_cnd, + "trde_prica_cnd": trde_prica_cnd, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_expected_execution_change_rate_request_ka10029( + self, + mrkt_tp: str, + sort_tp: str, + trde_qty_cnd: str, + stk_cnd: str, + crd_cnd: str, + pric_cnd: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """예상체결 등락률 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + sort_tp (str): 정렬구분 + - 1: 상승률 + - 2: 상승폭 + - 3: 보합 + - 4: 하락률 + - 5: 하락폭 + - 6: 체결량 + - 7: 상한 + - 8: 하한 + trde_qty_cnd (str): 거래량조건 + - 0: 전체조회 + - 1: 천주이상 + - 3: 3천주 + - 5: 5천주 + - 10: 만주이상 + - 50: 5만주이상 + - 100: 10만주이상 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 3: 우선주제외 + - 4: 관리종목,우선주제외 + - 5: 증100제외 + - 6: 증100만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + - 11: 정리매매종목제외 + - 12: 증50만보기 + - 13: 증60만보기 + - 14: ETF제외 + - 15: 스팩제외 + - 16: ETF+ETN제외 + crd_cnd (str): 신용조건 + - 0: 전체조회 + - 1: 신용융자A군 + - 2: 신용융자B군 + - 3: 신용융자C군 + - 4: 신용융자D군 + - 5: 신용한도초과제외 + - 8: 신용대주 + - 9: 신용융자전체 + pric_cnd (str): 가격조건 + - 0: 전체조회 + - 1: 1천원미만 + - 2: 1천원~2천원 + - 3: 2천원~5천원 + - 4: 5천원~1만원 + - 5: 1만원이상 + - 8: 1천원이상 + - 10: 1만원미만 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 예상체결 등락률 상위 종목 데이터 + { + "exp_cntr_flu_rt_upper": [ # 예상체결등락률상위 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "exp_cntr_pric": str, # 예상체결가 + "base_pric": str, # 기준가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "exp_cntr_qty": str, # 예상체결량 + "sel_req": str, # 매도잔량 + "sel_bid": str, # 매도호가 + "buy_bid": str, # 매수호가 + "buy_req": str, # 매수잔량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_expected_execution_change_rate_request_ka10029( + ... mrkt_tp="000", + ... sort_tp="1", + ... trde_qty_cnd="0", + ... stk_cnd="0", + ... crd_cnd="0", + ... pric_cnd="0", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10029", + } + + data = { + "mrkt_tp": mrkt_tp, + "sort_tp": sort_tp, + "trde_qty_cnd": trde_qty_cnd, + "stk_cnd": stk_cnd, + "crd_cnd": crd_cnd, + "pric_cnd": pric_cnd, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_trading_volume_today_request_ka10030( + self, + mrkt_tp: str, + sort_tp: str, + mang_stk_incls: str, + crd_tp: str, + trde_qty_tp: str, + pric_tp: str, + trde_prica_tp: str, + mrkt_open_tp: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """당일 거래량 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + sort_tp (str): 정렬구분 + - 1: 거래량 + - 2: 거래회전율 + - 3: 거래대금 + mang_stk_incls (str): 관리종목포함 + - 0: 관리종목 포함 + - 1: 관리종목 미포함 + - 3: 우선주제외 + - 11: 정리매매종목제외 + - 4: 관리종목, 우선주제외 + - 5: 증100제외 + - 6: 증100만보기 + - 13: 증60만보기 + - 12: 증50만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + - 14: ETF제외 + - 15: 스팩제외 + - 16: ETF+ETN제외 + crd_tp (str): 신용구분 + - 0: 전체조회 + - 9: 신용융자전체 + - 1: 신용융자A군 + - 2: 신용융자B군 + - 3: 신용융자C군 + - 4: 신용융자D군 + - 8: 신용대주 + trde_qty_tp (str): 거래량구분 + - 0: 전체조회 + - 5: 5천주이상 + - 10: 1만주이상 + - 50: 5만주이상 + - 100: 10만주이상 + - 200: 20만주이상 + - 300: 30만주이상 + - 500: 500만주이상 + - 1000: 백만주이상 + pric_tp (str): 가격구분 + - 0: 전체조회 + - 1: 1천원미만 + - 2: 1천원이상 + - 3: 1천원~2천원 + - 4: 2천원~5천원 + - 5: 5천원이상 + - 6: 5천원~1만원 + - 10: 1만원미만 + - 7: 1만원이상 + - 8: 5만원이상 + - 9: 10만원이상 + trde_prica_tp (str): 거래대금구분 + - 0: 전체조회 + - 1: 1천만원이상 + - 3: 3천만원이상 + - 4: 5천만원이상 + - 10: 1억원이상 + - 30: 3억원이상 + - 50: 5억원이상 + - 100: 10억원이상 + - 300: 30억원이상 + - 500: 50억원이상 + - 1000: 100억원이상 + - 3000: 300억원이상 + - 5000: 500억원이상 + mrkt_open_tp (str): 장운영구분 + - 0: 전체조회 + - 1: 장중 + - 2: 장전시간외 + - 3: 장후시간외 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 당일 거래량 상위 종목 데이터 + { + "tdy_trde_qty_upper": [ # 당일거래량상위 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "trde_qty": str, # 거래량 + "pred_rt": str, # 전일비 + "trde_tern_rt": str, # 거래회전율 + "trde_amt": str, # 거래금액 + "opmr_trde_qty": str, # 장중거래량 + "opmr_pred_rt": str, # 장중전일비 + "opmr_trde_rt": str, # 장중거래회전율 + "opmr_trde_amt": str, # 장중거래금액 + "af_mkrt_trde_qty": str, # 장후거래량 + "af_mkrt_pred_rt": str, # 장후전일비 + "af_mkrt_trde_rt": str, # 장후거래회전율 + "af_mkrt_trde_amt": str, # 장후거래금액 + "bf_mkrt_trde_qty": str, # 장전거래량 + "bf_mkrt_pred_rt": str, # 장전전일비 + "bf_mkrt_trde_rt": str, # 장전거래회전율 + "bf_mkrt_trde_amt": str, # 장전거래금액 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_trading_volume_today_request_ka10030( + ... mrkt_tp="000", + ... sort_tp="1", + ... mang_stk_incls="0", + ... crd_tp="0", + ... trde_qty_tp="0", + ... pric_tp="0", + ... trde_prica_tp="0", + ... mrkt_open_tp="0", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10030", + } + + data = { + "mrkt_tp": mrkt_tp, + "sort_tp": sort_tp, + "mang_stk_incls": mang_stk_incls, + "crd_tp": crd_tp, + "trde_qty_tp": trde_qty_tp, + "pric_tp": pric_tp, + "trde_prica_tp": trde_prica_tp, + "mrkt_open_tp": mrkt_open_tp, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_trading_volume_yesterday_request_ka10031( + self, + mrkt_tp: str, + qry_tp: str, + rank_strt: str, + rank_end: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """전일 거래량 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + qry_tp (str): 조회구분 + - 1: 전일거래량 상위100종목 + - 2: 전일거래대금 상위100종목 + rank_strt (str): 순위시작 (0 ~ 100 값 중에 조회를 원하는 순위 시작값) + rank_end (str): 순위끝 (0 ~ 100 값 중에 조회를 원하는 순위 끝값) + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 전일 거래량 상위 종목 데이터 + { + "pred_trde_qty_upper": [ # 전일거래량상위 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "trde_qty": str, # 거래량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_trading_volume_yesterday_request_ka10031( + ... mrkt_tp="101", + ... qry_tp="1", + ... rank_strt="0", + ... rank_end="10", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10031", + } + + data = { + "mrkt_tp": mrkt_tp, + "qry_tp": qry_tp, + "rank_strt": rank_strt, + "rank_end": rank_end, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_trading_value_request_ka10032( + self, + mrkt_tp: str, + mang_stk_incls: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """거래대금 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + mang_stk_incls (str): 관리종목포함 + - 0: 관리종목 미포함 + - 1: 관리종목 포함 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 거래대금 상위 종목 데이터 + { + "trde_prica_upper": [ # 거래대금상위 + { + "stk_cd": str, # 종목코드 + "now_rank": str, # 현재순위 + "pred_rank": str, # 전일순위 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "sel_bid": str, # 매도호가 + "buy_bid": str, # 매수호가 + "now_trde_qty": str, # 현재거래량 + "pred_trde_qty": str, # 전일거래량 + "trde_prica": str, # 거래대금 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_trading_value_request_ka10032( + ... mrkt_tp="001", + ... mang_stk_incls="1", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10032", + } + + data = { + "mrkt_tp": mrkt_tp, + "mang_stk_incls": mang_stk_incls, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_credit_ratio_request_ka10033( + self, + mrkt_tp: str, + trde_qty_tp: str, + stk_cnd: str, + updown_incls: str, + crd_cnd: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """신용비율 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + trde_qty_tp (str): 거래량구분 + - 0: 전체조회 + - 10: 만주이상 + - 50: 5만주이상 + - 100: 10만주이상 + - 200: 20만주이상 + - 300: 30만주이상 + - 500: 50만주이상 + - 1000: 백만주이상 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 5: 증100제외 + - 6: 증100만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + updown_incls (str): 상하한포함 + - 0: 상하한 미포함 + - 1: 상하한포함 + crd_cnd (str): 신용조건 + - 0: 전체조회 + - 1: 신용융자A군 + - 2: 신용융자B군 + - 3: 신용융자C군 + - 4: 신용융자D군 + - 9: 신용융자전체 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 신용비율 상위 종목 데이터 + { + "crd_rt_upper": [ # 신용비율상위 + { + "stk_infr": str, # 종목정보 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "crd_rt": str, # 신용비율 + "sel_req": str, # 매도잔량 + "buy_req": str, # 매수잔량 + "now_trde_qty": str, # 현재거래량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_credit_ratio_request_ka10033( + ... mrkt_tp="000", + ... trde_qty_tp="0", + ... stk_cnd="0", + ... updown_incls="1", + ... crd_cnd="0", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10033", + } + + data = { + "mrkt_tp": mrkt_tp, + "trde_qty_tp": trde_qty_tp, + "stk_cnd": stk_cnd, + "updown_incls": updown_incls, + "crd_cnd": crd_cnd, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_foreign_investor_trades_by_period_request_ka10034( + self, + mrkt_tp: str, + trde_tp: str, + dt: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """외국인 기간별 매매 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + trde_tp (str): 매매구분 + - 1: 순매도 + - 2: 순매수 + - 3: 순매매 + dt (str): 기간 + - 0: 당일 + - 1: 전일 + - 5: 5일 + - 10: 10일 + - 20: 20일 + - 60: 60일 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 외국인 기간별 매매 상위 종목 데이터 + { + "for_dt_trde_upper": [ # 외인기간별매매상위 + { + "rank": str, # 순위 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "sel_bid": str, # 매도호가 + "buy_bid": str, # 매수호가 + "trde_qty": str, # 거래량 + "netprps_qty": str, # 순매수량 + "gain_pos_stkcnt": str, # 취득가능주식수 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_foreign_investor_trades_by_period_request_ka10034( + ... mrkt_tp="001", + ... trde_tp="2", + ... dt="0", + ... stex_tp="1" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10034", + } + + data = { + "mrkt_tp": mrkt_tp, + "trde_tp": trde_tp, + "dt": dt, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_foreign_consecutive_net_buy_request_ka10035( + self, + mrkt_tp: str, + trde_tp: str, + base_dt_tp: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """외국인 연속 순매매 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + trde_tp (str): 매매구분 + - 1: 연속순매도 + - 2: 연속순매수 + base_dt_tp (str): 기준일구분 + - 0: 당일기준 + - 1: 전일기준 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 외국인 연속 순매매 상위 종목 데이터 + { + "for_cont_nettrde_upper": [ # 외인연속순매매상위 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "dm1": str, # D-1 + "dm2": str, # D-2 + "dm3": str, # D-3 + "tot": str, # 합계 + "limit_exh_rt": str, # 한도소진율 + "pred_pre_1": str, # 전일대비1 + "pred_pre_2": str, # 전일대비2 + "pred_pre_3": str, # 전일대비3 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_foreign_consecutive_net_buy_request_ka10035( + ... mrkt_tp="000", + ... trde_tp="2", + ... base_dt_tp="1", + ... stex_tp="1" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10035", + } + + data = { + "mrkt_tp": mrkt_tp, + "trde_tp": trde_tp, + "base_dt_tp": base_dt_tp, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_foreign_limit_utilization_increase_request_ka10036( + self, + mrkt_tp: str, + dt: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """외국인 한도소진율 증가 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + dt (str): 기간 + - 0: 당일 + - 1: 전일 + - 5: 5일 + - 10: 10일 + - 20: 20일 + - 60: 60일 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 외국인 한도소진율 증가 상위 종목 데이터 + { + "for_limit_exh_rt_incrs_upper": [ # 외인한도소진율증가상위 + { + "rank": str, # 순위 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "trde_qty": str, # 거래량 + "poss_stkcnt": str, # 보유주식수 + "gain_pos_stkcnt": str, # 취득가능주식수 + "base_limit_exh_rt": str, # 기준한도소진율 + "limit_exh_rt": str, # 한도소진율 + "exh_rt_incrs": str, # 소진율증가 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_foreign_limit_utilization_increase_request_ka10036( + ... mrkt_tp="000", + ... dt="1", + ... stex_tp="1" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10036", + } + + data = { + "mrkt_tp": mrkt_tp, + "dt": dt, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_foreign_broker_trading_request_ka10037( + self, + mrkt_tp: str, + dt: str, + trde_tp: str, + sort_tp: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """외국계 창구 매매 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + dt (str): 기간 + - 0: 당일 + - 1: 전일 + - 5: 5일 + - 10: 10일 + - 20: 20일 + - 60: 60일 + trde_tp (str): 매매구분 + - 1: 순매수 + - 2: 순매도 + sort_tp (str): 정렬구분 + - 1: 금액 + - 2: 수량 + stex_tp (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 외국계 창구 매매 상위 종목 데이터 + { + "frgn_wicket_trde_upper": [ # 외국계창구매매상위 + { + "rank": str, # 순위 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락율 + "sel_trde_qty": str, # 매도거래량 + "buy_trde_qty": str, # 매수거래량 + "netprps_trde_qty": str, # 순매수거래량 + "netprps_prica": str, # 순매수대금 + "trde_qty": str, # 거래량 + "trde_prica": str, # 거래대금 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_foreign_broker_trading_request_ka10037( + ... mrkt_tp="000", + ... dt="0", + ... trde_tp="1", + ... sort_tp="2", + ... stex_tp="1" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10037", + } + + data = { + "mrkt_tp": mrkt_tp, + "dt": dt, + "trde_tp": trde_tp, + "sort_tp": sort_tp, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def broker_ranking_by_stock_request_ka10038( + self, + stk_cd: str, + strt_dt: str, + end_dt: str, + qry_tp: str, + dt: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """종목별 증권사 순위를 조회합니다. + + Args: + stk_cd (str): 종목코드 (거래소별 종목코드) + - KRX: 039490 + - NXT: 039490_NX + - SOR: 039490_AL + strt_dt (str): 시작일자 (YYYYMMDD 형식) + end_dt (str): 종료일자 (YYYYMMDD 형식) + qry_tp (str): 조회구분 + - 1: 순매도순위정렬 + - 2: 순매수순위정렬 + dt (str): 기간 + - 1: 전일 + - 4: 5일 + - 9: 10일 + - 19: 20일 + - 39: 40일 + - 59: 60일 + - 119: 120일 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 종목별 증권사 순위 데이터 + { + "rank_1": str, # 순위1 + "rank_2": str, # 순위2 + "rank_3": str, # 순위3 + "prid_trde_qty": str, # 기간중거래량 + "stk_sec_rank": [ # 종목별증권사순위 + { + "rank": str, # 순위 + "mmcm_nm": str, # 회원사명 + "buy_qty": str, # 매수수량 + "sell_qty": str, # 매도수량 + "acc_netprps_qty": str, # 누적순매수수량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.broker_ranking_by_stock_request_ka10038( + ... stk_cd="005930", + ... strt_dt="20241106", + ... end_dt="20241107", + ... qry_tp="2", + ... dt="1" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10038", + } + + data = { + "stk_cd": stk_cd, + "strt_dt": strt_dt, + "end_dt": end_dt, + "qry_tp": qry_tp, + "dt": dt, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_broker_trading_request_ka10039( + self, + mmcm_cd: str, + trde_qty_tp: str, + trde_tp: str, + dt: str, + stex_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """증권사별 매매 상위 종목을 조회합니다. + + Args: + mmcm_cd (str): 회원사코드 (ka10102 API로 조회 가능) + trde_qty_tp (str): 거래량구분 + - 0: 전체 + - 5: 5000주 + - 10: 1만주 + - 50: 5만주 + - 100: 10만주 + - 500: 50만주 + - 1000: 100만주 + trde_tp (str): 매매구분 + - 1: 순매수 + - 2: 순매도 + dt (str): 기간 + - 1: 전일 + - 5: 5일 + - 10: 10일 + - 60: 60일 + stex_tp (str): 거래소구분 + - 1: KRX + - 2: NXT + - 3: 통합 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 증권사별 매매 상위 종목 데이터 + { + "sec_trde_upper": [ # 증권사별매매상위 + { + "rank": str, # 순위 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "prid_stkpc_flu": str, # 기간중주가등락 + "flu_rt": str, # 등락율 + "prid_trde_qty": str, # 기간중거래량 + "netprps": str, # 순매수 + "buy_trde_qty": str, # 매수거래량 + "sel_trde_qty": str, # 매도거래량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_broker_trading_request_ka10039( + ... mmcm_cd="001", + ... trde_qty_tp="0", + ... trde_tp="1", + ... dt="1", + ... stex_tp="3" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10039", + } + + data = { + "mmcm_cd": mmcm_cd, + "trde_qty_tp": trde_qty_tp, + "trde_tp": trde_tp, + "dt": dt, + "stex_tp": stex_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def main_trading_brokers_today_request_ka10040( + self, + stk_cd: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """당일 주요 거래원 정보를 조회합니다. + + Args: + stk_cd (str): 종목코드 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 당일 주요 거래원 데이터 + { + # 매도 거래원 정보 (1~5위) + "sel_trde_ori_irds_1": str, # 매도거래원별증감1 + "sel_trde_ori_qty_1": str, # 매도거래원수량1 + "sel_trde_ori_1": str, # 매도거래원1 + "sel_trde_ori_cd_1": str, # 매도거래원코드1 + # ... (2~5위 동일한 패턴) + + # 매수 거래원 정보 (1~5위) + "buy_trde_ori_1": str, # 매수거래원1 + "buy_trde_ori_cd_1": str, # 매수거래원코드1 + "buy_trde_ori_qty_1": str, # 매수거래원수량1 + "buy_trde_ori_irds_1": str, # 매수거래원별증감1 + # ... (2~5위 동일한 패턴) + + # 외국계 거래 정보 + "frgn_sel_prsm_sum_chang": str, # 외국계매도추정합변동 + "frgn_sel_prsm_sum": str, # 외국계매도추정합 + "frgn_buy_prsm_sum": str, # 외국계매수추정합 + "frgn_buy_prsm_sum_chang": str, # 외국계매수추정합변동 + + # 당일 주요 거래원 상세 정보 + "tdy_main_trde_ori": [ # 당일주요거래원 + { + "sel_scesn_tm": str, # 매도이탈시간 + "sell_qty": str, # 매도수량 + "sel_upper_scesn_ori": str, # 매도상위이탈원 + "buy_scesn_tm": str, # 매수이탈시간 + "buy_qty": str, # 매수수량 + "buy_upper_scesn_ori": str, # 매수상위이탈원 + "qry_dt": str, # 조회일자 + "qry_tm": str, # 조회시간 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.main_trading_brokers_today_request_ka10040( + ... stk_cd="005930" # 삼성전자 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10040", + } + + data = { + "stk_cd": stk_cd, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_net_buying_brokers_request_ka10042( + self, + stk_cd: str, + qry_dt_tp: str, + pot_tp: str, + sort_base: str, + strt_dt: str = "", + end_dt: str = "", + dt: str = "", + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """순매수거래원순위를 조회합니다. + + Args: + stk_cd (str): 종목코드 (거래소별 종목코드) + - KRX: 039490 + - NXT: 039490_NX + - SOR: 039490_AL + qry_dt_tp (str): 조회기간구분 + - 0: 기간으로 조회 + - 1: 시작일자, 종료일자로 조회 + pot_tp (str): 시점구분 + - 0: 당일 + - 1: 전일 + sort_base (str): 정렬기준 + - 1: 종가순 + - 2: 날짜순 + strt_dt (str, optional): 시작일자 (YYYYMMDD 형식). Defaults to "". + end_dt (str, optional): 종료일자 (YYYYMMDD 형식). Defaults to "". + dt (str, optional): 기간 + - 5: 5일 + - 10: 10일 + - 20: 20일 + - 40: 40일 + - 60: 60일 + - 120: 120일 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 순매수거래원순위 데이터 + { + "netprps_trde_ori_rank": [ # 순매수거래원순위 + { + "rank": str, # 순위 + "mmcm_cd": str, # 회원사코드 + "mmcm_nm": str, # 회원사명 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_net_buying_brokers_request_ka10042( + ... stk_cd="005930", + ... qry_dt_tp="0", + ... pot_tp="0", + ... sort_base="1", + ... dt="5" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10042", + } + + data = { + "stk_cd": stk_cd, + "qry_dt_tp": qry_dt_tp, + "pot_tp": pot_tp, + "sort_base": sort_base, + } + + # Optional parameters + if strt_dt: + data["strt_dt"] = strt_dt + if end_dt: + data["end_dt"] = end_dt + if dt: + data["dt"] = dt + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_departed_trading_brokers_today_request_ka10053( + self, + stk_cd: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """당일 상위 이탈원 정보를 조회합니다. + + Args: + stk_cd (str): 종목코드 (거래소별 종목코드) + - KRX: 039490 + - NXT: 039490_NX + - SOR: 039490_AL + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 당일 상위 이탈원 데이터 + { + "tdy_upper_scesn_ori": [ # 당일상위이탈원 + { + "sel_scesn_tm": str, # 매도이탈시간 + "sell_qty": str, # 매도수량 + "sel_upper_scesn_ori": str, # 매도상위이탈원 + "buy_scesn_tm": str, # 매수이탈시간 + "buy_qty": str, # 매수수량 + "buy_upper_scesn_ori": str, # 매수상위이탈원 + "qry_dt": str, # 조회일자 + "qry_tm": str, # 조회시간 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_departed_trading_brokers_today_request_ka10053( + ... stk_cd="005930" # 삼성전자 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10053", + } + + data = { + "stk_cd": stk_cd, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def same_day_net_buying_ranking_request_ka10062( + self, + strt_dt: str, + mrkt_tp: str, + trde_tp: str, + sort_cnd: str, + unit_tp: str, + stex_tp: str, + end_dt: str = "", + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """동일순매매순위를 조회합니다. + + Args: + strt_dt (str): 시작일자 (YYYYMMDD 형식) + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + trde_tp (str): 매매구분 + - 1: 순매수 + - 2: 순매도 + sort_cnd (str): 정렬조건 + - 1: 수량 + - 2: 금액 + unit_tp (str): 단위구분 + - 1: 단주 + - 1000: 천주 + stex_tp (str): 거래소구분 + - 1: KRX + - 2: NXT + - 3: 통합 + end_dt (str, optional): 종료일자 (YYYYMMDD 형식). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 동일순매매순위 데이터 + { + "eql_nettrde_rank": [ # 동일순매매순위 + { + "stk_cd": str, # 종목코드 + "rank": str, # 순위 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pre_sig": str, # 대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락율 + "acc_trde_qty": str, # 누적거래량 + "orgn_nettrde_qty": str, # 기관순매매수량 + "orgn_nettrde_amt": str, # 기관순매매금액 + "orgn_nettrde_avg_pric": str, # 기관순매매평균가 + "for_nettrde_qty": str, # 외인순매매수량 + "for_nettrde_amt": str, # 외인순매매금액 + "for_nettrde_avg_pric": str, # 외인순매매평균가 + "nettrde_qty": str, # 순매매수량 + "nettrde_amt": str, # 순매매금액 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.same_day_net_buying_ranking_request_ka10062( + ... strt_dt="20241106", + ... mrkt_tp="000", + ... trde_tp="1", + ... sort_cnd="1", + ... unit_tp="1", + ... stex_tp="3", + ... end_dt="20241107" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10062", + } + + data = { + "strt_dt": strt_dt, + "mrkt_tp": mrkt_tp, + "trde_tp": trde_tp, + "sort_cnd": sort_cnd, + "unit_tp": unit_tp, + "stex_tp": stex_tp, + } + + # Optional parameter + if end_dt: + data["end_dt"] = end_dt + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_intraday_investor_trading_request_ka10065( + self, + trde_tp: str, + mrkt_tp: str, + orgn_tp: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """장중 투자자별 매매 상위 종목을 조회합니다. + + Args: + trde_tp (str): 매매구분 + - 1: 순매수 + - 2: 순매도 + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + orgn_tp (str): 기관구분 + - 9000: 외국인 + - 9100: 외국계 + - 1000: 금융투자 + - 3000: 투신 + - 5000: 기타금융 + - 4000: 은행 + - 2000: 보험 + - 6000: 연기금 + - 7000: 국가 + - 7100: 기타법인 + - 9999: 기관계 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 장중 투자자별 매매 상위 종목 데이터 + { + "opmr_invsr_trde_upper": [ # 장중투자자별매매상위 + { + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "sel_qty": str, # 매도량 + "buy_qty": str, # 매수량 + "netslmt": str, # 순매도 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_intraday_investor_trading_request_ka10065( + ... trde_tp="1", # 순매수 + ... mrkt_tp="000", # 전체 + ... orgn_tp="9000" # 외국인 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10065", + } + + data = { + "trde_tp": trde_tp, + "mrkt_tp": mrkt_tp, + "orgn_tp": orgn_tp, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def after_market_price_change_rate_ranking_request_ka10098( + self, + mrkt_tp: str, + sort_base: str, + stk_cnd: str, + trde_qty_cnd: str, + crd_cnd: str, + trde_prica: str, + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """시간외 단일가 등락율 순위를 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + sort_base (str): 정렬기준 + - 1: 상승률 + - 2: 상승폭 + - 3: 하락률 + - 4: 하락폭 + - 5: 보합 + stk_cnd (str): 종목조건 + - 0: 전체조회 + - 1: 관리종목제외 + - 2: 정리매매종목제외 + - 3: 우선주제외 + - 4: 관리종목우선주제외 + - 5: 증100제외 + - 6: 증100만보기 + - 7: 증40만보기 + - 8: 증30만보기 + - 9: 증20만보기 + - 12: 증50만보기 + - 13: 증60만보기 + - 14: ETF제외 + - 15: 스팩제외 + - 16: ETF+ETN제외 + - 17: ETN제외 + trde_qty_cnd (str): 거래량조건 + - 0: 전체조회 + - 10: 백주이상 + - 50: 5백주이상 + - 100: 천주이상 + - 500: 5천주이상 + - 1000: 만주이상 + - 5000: 5만주이상 + - 10000: 10만주이상 + crd_cnd (str): 신용조건 + - 0: 전체조회 + - 9: 신용융자전체 + - 1: 신용융자A군 + - 2: 신용융자B군 + - 3: 신용융자C군 + - 4: 신용융자D군 + - 8: 신용대주 + - 5: 신용한도초과제외 + trde_prica (str): 거래대금 + - 0: 전체조회 + - 5: 5백만원이상 + - 10: 1천만원이상 + - 30: 3천만원이상 + - 50: 5천만원이상 + - 100: 1억원이상 + - 300: 3억원이상 + - 500: 5억원이상 + - 1000: 10억원이상 + - 3000: 30억원이상 + - 5000: 50억원이상 + - 10000: 100억원이상 + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 시간외 단일가 등락율 순위 데이터 + { + "ovt_sigpric_flu_rt_rank": [ # 시간외단일가등락율순위 + { + "rank": str, # 순위 + "stk_cd": str, # 종목코드 + "stk_nm": str, # 종목명 + "cur_prc": str, # 현재가 + "pred_pre_sig": str, # 전일대비기호 + "pred_pre": str, # 전일대비 + "flu_rt": str, # 등락률 + "sel_tot_req": str, # 매도총잔량 + "buy_tot_req": str, # 매수총잔량 + "acc_trde_qty": str, # 누적거래량 + "acc_trde_prica": str, # 누적거래대금 + "tdy_close_pric": str, # 당일종가 + "tdy_close_pric_flu_rt": str, # 당일종가등락률 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.after_market_price_change_rate_ranking_request_ka10098( + ... mrkt_tp="000", # 전체 + ... sort_base="5", # 보합 + ... stk_cnd="0", # 전체조회 + ... trde_qty_cnd="0", # 전체조회 + ... crd_cnd="0", # 전체조회 + ... trde_prica="0" # 전체조회 + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10098", + } + + data = { + "mrkt_tp": mrkt_tp, + "sort_base": sort_base, + "stk_cnd": stk_cnd, + "trde_qty_cnd": trde_qty_cnd, + "crd_cnd": crd_cnd, + "trde_prica": trde_prica, + } + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) + + def top_foreign_institution_trades_request_ka90009( + self, + mrkt_tp: str, + amt_qty_tp: str, + qry_dt_tp: str, + stex_tp: str, + date: str = "", + cont_yn: str = "N", + next_key: str = "", + ) -> dict: + """외국인/기관 매매 상위 종목을 조회합니다. + + Args: + mrkt_tp (str): 시장구분 + - 000: 전체 + - 001: 코스피 + - 101: 코스닥 + amt_qty_tp (str): 금액수량구분 + - 1: 금액(천만) + - 2: 수량(천) + qry_dt_tp (str): 조회일자구분 + - 0: 조회일자 미포함 + - 1: 조회일자 포함 + stex_tp (str): 거래소구분 + - 1: KRX + - 2: NXT + - 3: 통합 + date (str, optional): 날짜 (YYYYMMDD 형식). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + dict: 외국인/기관 매매 상위 종목 데이터 + { + "frgnr_orgn_trde_upper": [ # 외국인기관매매상위 + { + "for_netslmt_stk_cd": str, # 외인순매도종목코드 + "for_netslmt_stk_nm": str, # 외인순매도종목명 + "for_netslmt_amt": str, # 외인순매도금액 + "for_netslmt_qty": str, # 외인순매도수량 + "for_netprps_stk_cd": str, # 외인순매수종목코드 + "for_netprps_stk_nm": str, # 외인순매수종목명 + "for_netprps_amt": str, # 외인순매수금액 + "for_netprps_qty": str, # 외인순매수수량 + "orgn_netslmt_stk_cd": str, # 기관순매도종목코드 + "orgn_netslmt_stk_nm": str, # 기관순매도종목명 + "orgn_netslmt_amt": str, # 기관순매도금액 + "orgn_netslmt_qty": str, # 기관순매도수량 + "orgn_netprps_stk_cd": str, # 기관순매수종목코드 + "orgn_netprps_stk_nm": str, # 기관순매수종목명 + "orgn_netprps_amt": str, # 기관순매수금액 + "orgn_netprps_qty": str, # 기관순매수수량 + }, + ... + ], + "return_code": int, # 응답코드 + "return_msg": str, # 응답메시지 + } + + Example: + >>> from kiwoom_rest_api import KiwoomRestAPI + >>> api = KiwoomRestAPI() + >>> result = api.rank_info.top_foreign_institution_trades_request_ka90009( + ... mrkt_tp="000", # 전체 + ... amt_qty_tp="1", # 금액(천만) + ... qry_dt_tp="1", # 조회일자 포함 + ... stex_tp="1", # KRX + ... date="20241101" + ... ) + >>> print(result) + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90009", + } + + data = { + "mrkt_tp": mrkt_tp, + "amt_qty_tp": amt_qty_tp, + "qry_dt_tp": qry_dt_tp, + "stex_tp": stex_tp, + } + + # Optional parameter + if date: + data["date"] = date + + return self._execute_request( + "POST", + json=data, + headers=headers, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/sector.py b/kiwoom_rest_api/koreanstock/sector.py new file mode 100644 index 0000000..6294465 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/sector.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/slb.py b/kiwoom_rest_api/koreanstock/slb.py new file mode 100644 index 0000000..75daef0 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/slb.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/stockinfo.py b/kiwoom_rest_api/koreanstock/stockinfo.py new file mode 100644 index 0000000..4856c26 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/stockinfo.py @@ -0,0 +1,1608 @@ +from typing import Dict, Optional, Any, List, Union, Callable, Awaitable + +from kiwoom_rest_api.core.sync_client import make_request +from kiwoom_rest_api.core.async_client import make_request_async +from kiwoom_rest_api.core.base_api import KiwoomBaseAPI + +class StockInfo(KiwoomBaseAPI): + """한국 주식 종목 정보 관련 API를 제공하는 클래스""" + + def __init__( + self, + base_url: str = None, + token_manager=None, + use_async: bool = False, + resource_url: str = "/api/dostk/stkinfo" + ): + """ + StockInfo 클래스 초기화 + + 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 basic_stock_information_request_ka10001( + self, stock_code: str, cont_yn: str = "N", next_key: str = "0" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """ + 주식기본정보요청 + API ID: ka10001 + + Args: + stock_code (str): 종목코드 (예: '005930') + + Returns: + Dict[str, Any] or Awaitable[Dict[str, Any]]: 주식 기본 정보 + """ + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10001" + } + + body = { + "stk_cd": stock_code, + } + + + + return self._execute_request("POST", json=body, headers=headers) + + def stock_trading_agent_request_ka10002( + self, stock_code: str, cont_yn: str = "N", next_key: str = "0" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """ + 주식 거래원 요청 + API ID (TR_ID): ka10002 (명세서 예시 ID, 실제 TR ID 확인 필요) + + Args: + stock_code (str): 종목코드 (예: '005930', 'KRX:039490') + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Dict[str, Any] or Awaitable[Dict[str, Any]]: 현재가 정보 딕셔너리 또는 Awaitable 객체 + """ + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10002" + } + + body = { + "stk_cd": stock_code, + } + + return self._execute_request("POST", json=body, headers=headers) + + def daily_stock_price_request_ka10003( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """ + 체결 정보 요청 + API ID (TR_ID): ka10003 (명세서 예시 ID, 실제 TR ID 확인 필요) + + Args: + stock_code (str): 종목코드 (예: '005930', 'KRX:039490') + cont_yn (str, optional): 연속조회여부. 응답 헤더의 값을 사용. Defaults to "N". + next_key (str, optional): 연속조회키. 응답 헤더의 값을 사용. Defaults to "". + + Returns: + Dict[str, Any] or Awaitable[Dict[str, Any]]: 체결 정보 딕셔너리 또는 Awaitable 객체 + """ + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10003" + } + + body = { + "stk_cd": stock_code, + } + + return self._execute_request("POST", json=body, headers=headers) + + def credit_trading_trend_request_ka10013( + self, + stock_code: str, + date: str, + query_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """신용매매동향 요청 + + Args: + stock_code (str): 종목코드 (예: "005930") + date (str): 조회 일자 (YYYYMMDD 형식) + query_type (str): 조회구분 (1:융자, 2:대주) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "crd_trde_trend": [ + { + "dt": "20241101", + "cur_prc": "65100", + "pred_pre_sig": "0", + "pred_pre": "0", + "trde_qty": "0", + "new": "", + "rpya": "", + "remn": "", + "amt": "", + "pre": "", + "shr_rt": "", + "remn_rt": "" + }, + ... + ] + } + """ + + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10013" + } + + body = { + "stk_cd": stock_code, + "dt": date, + "qry_tp": query_type, + } + + return self._execute_request("POST", json=body, headers=headers) + + def daily_transaction_details_request_ka10015( + self, + stock_code: str, + start_date: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """일별거래상세요청 + + Args: + stock_code (str): 종목코드 (예: "005930") + start_date (str): 시작일자 (YYYYMMDD 형식) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "daly_trde_dtl": [ + { + "dt": "20241105", + "close_pric": "135300", + "pred_pre_sig": "0", + "pred_pre": "0", + "flu_rt": "0.00", + "trde_qty": "0", + "trde_prica": "0", + "bf_mkrt_trde_qty": "", + "bf_mkrt_trde_wght": "", + "opmr_trde_qty": "", + "opmr_trde_wght": "", + "af_mkrt_trde_qty": "", + "af_mkrt_trde_wght": "", + "tot_3": "0", + "prid_trde_qty": "0", + "cntr_str": "", + "for_poss": "", + "for_wght": "", + "for_netprps": "", + "orgn_netprps": "", + "ind_netprps": "", + "frgn": "", + "crd_remn_rt": "", + "prm": "", + "bf_mkrt_trde_prica": "", + "bf_mkrt_trde_prica_wght": "", + "opmr_trde_prica": "", + "opmr_trde_prica_wght": "", + "af_mkrt_trde_prica": "", + "af_mkrt_trde_prica_wght": "" + }, + ... + ] + } + """ + + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10015" + } + + # 요청 데이터 구성 + data = { + "stk_cd": stock_code, + "strt_dt": start_date + } + + return self._execute_request("POST", json=data, headers=headers) + + def reported_low_price_request_ka10016( + self, + market_type: str, + report_type: str, + high_low_close_type: str, + stock_condition: str, + trade_quantity_type: str, + credit_condition: str, + updown_include: str, + period: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """신고저가 요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + report_type (str): 신고저구분 (1:신고가, 2:신저가) + high_low_close_type (str): 고저종구분 (1:고저기준, 2:종가기준) + stock_condition (str): 종목조건 (0:전체조회, 1:관리종목제외, 3:우선주제외, 5:증100제외, 6:증100만보기, 7:증40만보기, 8:증30만보기) + trade_quantity_type (str): 거래량구분 (00000:전체조회, 00010:만주이상, 00050:5만주이상, 00100:10만주이상, ...) + credit_condition (str): 신용조건 (0:전체조회, 1:신용융자A군, 2:신용융자B군, 3:신용융자C군, 4:신용융자D군, 9:신용융자전체) + updown_include (str): 상하한포함 (0:미포함, 1:포함) + period (str): 기간 (5:5일, 10:10일, 20:20일, 60:60일, 250:250일) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "ntl_pric": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "334", + "pred_pre_sig": "3", + "pred_pre": "0", + "flu_rt": "0.00", + "trde_qty": "3", + "pred_trde_qty_pre_rt": "-0.00", + "sel_bid": "0", + "buy_bid": "0", + "high_pric": "334", + "low_pric": "320" + }, + ... + ] + } + """ + + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10016" + } + + # 요청 데이터 구성 + data = { + "mrkt_tp": market_type, + "ntl_tp": report_type, + "high_low_close_tp": high_low_close_type, + "stk_cnd": stock_condition, + "trde_qty_tp": trade_quantity_type, + "crd_cnd": credit_condition, + "updown_incls": updown_include, + "dt": period, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def upper_lower_limit_price_request_ka10017( + self, + market_type: str, + updown_type: str, + sort_type: str, + stock_condition: str, + trade_quantity_type: str, + credit_condition: str, + trade_gold_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """상하한가 요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + updown_type (str): 상하한구분 (1:상한, 2:상승, 3:보합, 4:하한, 5:하락, 6:전일상한, 7:전일하한) + sort_type (str): 정렬구분 (1:종목코드순, 2:연속횟수순(상위100개), 3:등락률순) + stock_condition (str): 종목조건 (0:전체조회, 1:관리종목제외, 3:우선주제외, ...) + trade_quantity_type (str): 거래량구분 (00000:전체조회, 00010:만주이상, ...) + credit_condition (str): 신용조건 (0:전체조회, 1:신용융자A군, ...) + trade_gold_type (str): 매매금구분 (0:전체조회, 1:1천원미만, 2:1천원~2천원, ...) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "updown_pric": [ + { + "stk_cd": "005930", + "stk_infr": "", + "stk_nm": "삼성전자", + "cur_prc": "+235500", + "pred_pre_sig": "1", + "pred_pre": "+54200", + "flu_rt": "+29.90", + "trde_qty": "0", + "pred_trde_qty": "96197", + "sel_req": "0", + "sel_bid": "0", + "buy_bid": "+235500", + "buy_req": "4", + "cnt": "1" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10017" + } + + # 요청 데이터 구성 + data = { + "mrkt_tp": market_type, + "updown_tp": updown_type, + "sort_tp": sort_type, + "stk_cnd": stock_condition, + "trde_qty_tp": trade_quantity_type, + "crd_cnd": credit_condition, + "trde_gold_tp": trade_gold_type, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def near_high_low_price_request_ka10018( + self, + high_low_type: str, + approach_rate: str, + market_type: str, + trade_quantity_type: str, + stock_condition: str, + credit_condition: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """고저가근접 요청 + + Args: + high_low_type (str): 고저구분 (1:고가, 2:저가) + approach_rate (str): 근접율 (05:0.5, 10:1.0, 15:1.5, 20:2.0, 25:2.5, 30:3.0) + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + trade_quantity_type (str): 거래량구분 (00000:전체조회, 00010:만주이상, ...) + stock_condition (str): 종목조건 (0:전체조회, 1:관리종목제외, ...) + credit_condition (str): 신용조건 (0:전체조회, 1:신용융자A군, ...) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "high_low_pric_alacc": [ + { + "stk_cd": "004930", + "stk_nm": "삼성전자", + "cur_prc": "334", + "pred_pre_sig": "0", + "pred_pre": "0", + "flu_rt": "0.00", + "trde_qty": "3", + "sel_bid": "0", + "buy_bid": "0", + "tdy_high_pric": "334", + "tdy_low_pric": "334" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10018" + } + + # 요청 데이터 구성 + data = { + "high_low_tp": high_low_type, + "alacc_rt": approach_rate, + "mrkt_tp": market_type, + "trde_qty_tp": trade_quantity_type, + "stk_cnd": stock_condition, + "crd_cnd": credit_condition, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def rapid_price_change_request_ka10019( + self, + market_type: str, + fluctuation_type: str, + time_type: str, + time: str, + trade_quantity_type: str, + stock_condition: str, + credit_condition: str, + price_condition: str, + updown_include: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """가격급등락 요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥, 201:코스피200) + fluctuation_type (str): 등락구분 (1:급등, 2:급락) + time_type (str): 시간구분 (1:분전, 2:일전) + time (str): 시간 (분 혹은 일 입력) + trade_quantity_type (str): 거래량구분 (00000:전체조회, 00010:만주이상, ...) + stock_condition (str): 종목조건 (0:전체조회, 1:관리종목제외, ...) + credit_condition (str): 신용조건 (0:전체조회, 1:신용융자A군, ...) + price_condition (str): 가격조건 (0:전체조회, 1:1천원미만, ...) + updown_include (str): 상하한포함 (0:미포함, 1:포함) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "pric_jmpflu": [ + { + "stk_cd": "005930", + "stk_cls": "", + "stk_nm": "삼성전자", + "pred_pre_sig": "2", + "pred_pre": "+300", + "flu_rt": "+0.57", + "base_pric": "51600", + "cur_prc": "+52700", + "base_pre": "1100", + "trde_qty": "2400", + "jmp_rt": "+2.13" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10019" + } + + # 요청 데이터 구성 + data = { + "mrkt_tp": market_type, + "flu_tp": fluctuation_type, + "tm_tp": time_type, + "tm": time, + "trde_qty_tp": trade_quantity_type, + "stk_cnd": stock_condition, + "crd_cnd": credit_condition, + "pric_cnd": price_condition, + "updown_incls": updown_include, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def trading_volume_update_request_ka10024( + self, + market_type: str, + cycle_type: str, + trade_quantity_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """거래량갱신 요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + cycle_type (str): 주기구분 (5:5일, 10:10일, 20:20일, 60:60일, 250:250일) + trade_quantity_type (str): 거래량구분 (5:5천주이상, 10:만주이상, 50:5만주이상, 100:10만주이상, ...) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "trde_qty_updt": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "+74800", + "pred_pre_sig": "1", + "pred_pre": "+17200", + "flu_rt": "+29.86", + "prev_trde_qty": "243520", + "now_trde_qty": "435771", + "sel_bid": "0", + "buy_bid": "+74800" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10024" + } + + # 요청 데이터 구성 + data = { + "mrkt_tp": market_type, + "cycle_tp": cycle_type, + "trde_qty_tp": trade_quantity_type, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def supply_concentration_request_ka10025( + self, + market_type: str, + supply_concentration_rate: str, + current_price_entry: str, + supply_count: str, + cycle_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """매물대집중 요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + supply_concentration_rate (str): 매물집중비율 (0~100 입력) + current_price_entry (str): 현재가진입 (0:현재가 매물대 진입 포함안함, 1:현재가 매물대 진입포함) + supply_count (str): 매물대수 (숫자입력) + cycle_type (str): 주기구분 (50:50일, 100:100일, 150:150일, 200:200일, 250:250일, 300:300일) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "prps_cnctr": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "30000", + "pred_pre_sig": "3", + "pred_pre": "0", + "flu_rt": "0.00", + "now_trde_qty": "0", + "pric_strt": "31350", + "pric_end": "31799", + "prps_qty": "4", + "prps_rt": "+50.00" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10025" + } + + # 요청 데이터 구성 + data = { + "mrkt_tp": market_type, + "prps_cnctr_rt": supply_concentration_rate, + "cur_prc_entry": current_price_entry, + "prpscnt": supply_count, + "cycle_tp": cycle_type, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def high_low_per_request_ka10026( + self, + per_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """고저PER 요청 + + Args: + per_type (str): PER구분 (1:저PBR, 2:고PBR, 3:저PER, 4:고PER, 5:저ROE, 6:고ROE) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "high_low_per": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "per": "0.44", + "cur_prc": "4930", + "pred_pre_sig": "3", + "pred_pre": "0", + "flu_rt": "0.00", + "now_trde_qty": "0", + "sel_bid": "0" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10026" + } + + # 요청 데이터 구성 + data = { + "pertp": per_type, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def rate_of_change_compared_to_opening_price_request_ka10028( + self, + sort_type: str, + trade_quantity_condition: str, + market_type: str, + updown_include: str, + stock_condition: str, + credit_condition: str, + trade_price_condition: str, + fluctuation_condition: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """시가대비등락률 요청 + + Args: + sort_type (str) = 정렬구분 (1:시가, 2:고가, 3:저가, 4:기준가) + trade_quantity_condition (str) = 거래량조건 (0000:전체조회, 0010:만주이상, ...) + market_type (str) = 시장구분 (000:전체, 001:코스피, 101:코스닥) + updown_include (str) = 상하한포함 (0:불포함, 1:포함) + stock_condition (str) = 종목조건 (0:전체조회, 1:관리종목제외, ...) + credit_condition (str) = 신용조건 (0:전체조회, 1:신용융자A군, ...) + trade_price_condition (str) = 거래대금조건 (0:전체조회, 3:3천만원이상, ...) + fluctuation_condition (str) = 등락조건 (1:상위, 2:하위) + stock_exchange_type (str) = 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional) = 연속조회여부. Defaults to "N". + next_key (str, optional) = 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "open_pric_pre_flu_rt": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "+74800", + "pred_pre_sig": "1", + "pred_pre": "+17200", + "flu_rt": "+29.86", + "open_pric": "+65000", + "high_pric": "+74800", + "low_pric": "-57000", + "open_pric_pre": "+15.08", + "now_trde_qty": "448203", + "cntr_str": "346.54" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10028" + } + + # 요청 데이터 구성 + data = { + "sort_tp": sort_type, + "trde_qty_cnd": trade_quantity_condition, + "mrkt_tp": market_type, + "updown_incls": updown_include, + "stk_cnd": stock_condition, + "crd_cnd": credit_condition, + "trde_prica_cnd": trade_price_condition, + "flu_cnd": fluctuation_condition, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def trading_agent_supply_demand_analysis_request_ka10043( + self, + stock_code: str, + start_date: str, + end_date: str, + query_date_type: str, + point_type: str, + period: str, + sort_base: str, + member_code: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """거래원매물대분석 요청 + + Args: + stock_code (str) = 종목코드 (예: "005930") + start_date (str) = 시작일자 (YYYYMMDD 형식) + end_date (str) = 종료일자 (YYYYMMDD 형식) + query_date_type (str) = 조회기간구분 (0:기간으로 조회, 1:시작일자, 종료일자로 조회) + point_type (str) = 시점구분 (0:당일, 1:전일) + period (str) = 기간 (5:5일, 10:10일, 20:20일, 40:40일, 60:60일, 120:120일) + sort_base (str) = 정렬기준 (1:종가순, 2:날짜순) + member_code (str) = 회원사코드 (회원사 코드는 ka10102 조회) + stock_exchange_type (str) = 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional) = 연속조회여부. Defaults to "N". + next_key (str, optional) = 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "trde_ori_prps_anly": [ + { + "dt": "20241105", + "close_pric": "135300", + "pre_sig": "2", + "pred_pre": "+1700", + "sel_qty": "43", + "buy_qty": "1090", + "netprps_qty": "1047", + "trde_qty_sum": "1133", + "trde_wght": "+1317.44" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10043" + } + + # 요청 데이터 구성 + data = { + "stk_cd": stock_code, + "strt_dt": start_date, + "end_dt": end_date, + "qry_dt_tp": query_date_type, + "pot_tp": point_type, + "dt": period, + "sort_base": sort_base, + "mmcm_cd": member_code, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def trading_agent_instant_trading_volume_request_ka10052( + self, + member_code: str, + stock_code: str = "", + market_type: str = "0", + quantity_type: str = "0", + price_type: str = "0", + stock_exchange_type: str = "3", + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """거래원순간거래량 요청 + + Args: + member_code (str): 회원사코드 (회원사 코드는 ka10102 조회) + stock_code (str, optional): 종목코드. Defaults to "". + market_type (str, optional): 시장구분 (0:전체, 1:코스피, 2:코스닥, 3:종목). Defaults to "0". + quantity_type (str, optional): 수량구분 (0:전체, 1:1000주, 2:2000주, 10:10000주, ...). Defaults to "0". + price_type (str, optional): 가격구분 (0:전체, 1:1천원 미만, 8:1천원 이상, ...). Defaults to "0". + stock_exchange_type (str, optional): 거래소구분 (1:KRX, 2:NXT, 3:통합). Defaults to "3". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "trde_ori_mont_trde_qty": [ + { + "tm": "161437", + "stk_cd": "005930", + "stk_nm": "삼성전자", + "trde_ori_nm": "다이와", + "tp": "-매도", + "mont_trde_qty": "-399928", + "acc_netprps": "-1073004", + "cur_prc": "+57700", + "pred_pre_sig": "2", + "pred_pre": "400", + "flu_rt": "+0.70" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10052" + } + + # 요청 데이터 구성 + data = { + "mmcm_cd": member_code, + "stk_cd": stock_code, + "mrkt_tp": market_type, + "qty_tp": quantity_type, + "pric_tp": price_type, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def volatility_mitigation_device_triggered_stocks_request_ka10054( + self, + market_type: str, + before_market_type: str, + stock_code: str = "", + motion_type: str = "0", + skip_stock: str = "000000000", + trade_quantity_type: str = "0", + min_trade_quantity: str = "0", + max_trade_quantity: str = "0", + trade_price_type: str = "0", + min_trade_price: str = "0", + max_trade_price: str = "0", + motion_direction: str = "0", + stock_exchange_type: str = "3", + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """변동성완화장치발동종목 요청 + + Args: + market_type (str): 시장구분 (000:전체, 001:코스피, 101:코스닥) + before_market_type (str): 장전구분 (0:전체, 1:정규시장, 2:시간외단일가) + stock_code (str, optional): 종목코드. Defaults to "". + motion_type (str, optional): 발동구분 (0:전체, 1:정적VI, 2:동적VI, 3:동적VI + 정적VI). Defaults to "0". + skip_stock (str, optional): 제외종목 (000000000:전종목포함, 111111111:전종목제외). Defaults to "000000000". + trade_quantity_type (str, optional): 거래량구분 (0:사용안함, 1:사용). Defaults to "0". + min_trade_quantity (str, optional): 최소거래량. Defaults to "0". + max_trade_quantity (str, optional): 최대거래량. Defaults to "0". + trade_price_type (str, optional): 거래대금구분 (0:사용안함, 1:사용). Defaults to "0". + min_trade_price (str, optional): 최소거래대금. Defaults to "0". + max_trade_price (str, optional): 최대거래대금. Defaults to "0". + motion_direction (str, optional): 발동방향 (0:전체, 1:상승, 2:하락). Defaults to "0". + stock_exchange_type (str, optional): 거래소구분 (1:KRX, 2:NXT, 3:통합). Defaults to "3". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "motn_stk": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "acc_trde_qty": "1105968", + "motn_pric": "67000", + "dynm_dispty_rt": "+9.30", + "trde_cntr_proc_time": "172311", + "virelis_time": "172511", + "viaplc_tp": "동적", + "dynm_stdpc": "61300", + "static_stdpc": "0", + "static_dispty_rt": "0.00", + "open_pric_pre_flu_rt": "+16.93", + "vimotn_cnt": "23", + "stex_tp": "NXT" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10054" + } + + # 요청 데이터 구성 + data = { + "mrkt_tp": market_type, + "bf_mkrt_tp": before_market_type, + "stk_cd": stock_code, + "motn_tp": motion_type, + "skip_stk": skip_stock, + "trde_qty_tp": trade_quantity_type, + "min_trde_qty": min_trade_quantity, + "max_trde_qty": max_trade_quantity, + "trde_prica_tp": trade_price_type, + "min_trde_prica": min_trade_price, + "max_trde_prica": max_trade_price, + "motn_drc": motion_direction, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def today_vs_previous_day_execution_volume_request_ka10055( + self, + stock_code: str, + today_or_previous: str, + market_type: str = "", + stock_exchange_type: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """당일전일체결량 요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + today_or_previous (str): 당일전일 (1:당일, 2:전일) + market_type (str, optional): 시장구분. Defaults to "". + stock_exchange_type (str, optional): 거래소구분. Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "tdy_pred_cntr_qty": [ + { + "cntr_tm": "171945", + "cntr_pric": "+74800", + "pred_pre_sig": "1", + "pred_pre": "+17200", + "flu_rt": "+29.86", + "cntr_qty": "-1793", + "acc_trde_qty": "446203", + "acc_trde_prica": "33225" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10055" + } + + # 요청 데이터 구성 + data = { + "stk_cd": stock_code, + "tdy_pred": today_or_previous + } + + return self._execute_request("POST", json=data, headers=headers) + + def daily_trading_stocks_by_investor_type_request_ka10058( + self, + start_date: str, + end_date: str, + trade_type: str, + market_type: str, + investor_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """투자자별일별매매종목 요청 + + Args: + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + trade_type (str): 매매구분 (순매도:1, 순매수:2) + market_type (str): 시장구분 (001:코스피, 101:코스닥) + investor_type (str): 투자자구분 (8000:개인, 9000:외국인, 1000:금융투자, 3000:투신, + 5000:기타금융, 4000:은행, 2000:보험, 6000:연기금, 7000:국가, + 7100:기타법인, 9999:기관계) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "invsr_daly_trde_stk": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "netslmt_qty": "+4464", + "netslmt_amt": "+25467", + "prsm_avg_pric": "57056", + "cur_prc": "+61300", + "pre_sig": "2", + "pred_pre": "+4000", + "avg_pric_pre": "+4244", + "pre_rt": "+7.43", + "dt_trde_qty": "1554171" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10058" + } + + # 요청 데이터 구성 + data = { + "strt_dt": start_date, + "end_dt": end_date, + "trde_tp": trade_type, + "mrkt_tp": market_type, + "invsr_tp": investor_type, + "stex_tp": stock_exchange_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def stock_data_by_investor_institution_request_ka10059( + self, + date: str, + stock_code: str, + amount_quantity_type: str, + trade_type: str, + unit_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목별투자자기관별 요청 + + Args: + date (str): 일자 (YYYYMMDD) + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + amount_quantity_type (str): 금액수량구분 (1:금액, 2:수량) + trade_type (str): 매매구분 (0:순매수, 1:매수, 2:매도) + unit_type (str): 단위구분 (1000:천주, 1:단주) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "stk_invsr_orgn": [ + { + "dt": "20241107", + "cur_prc": "+61300", + "pre_sig": "2", + "pred_pre": "+4000", + "flu_rt": "+698", + "acc_trde_qty": "1105968", + "acc_trde_prica": "64215", + "ind_invsr": "1584", + "frgnr_invsr": "-61779", + "orgn": "60195", + "fnnc_invt": "25514", + "insrnc": "0", + "invtrt": "0", + "etc_fnnc": "34619", + "bank": "4", + "penfnd_etc": "-1", + "samo_fund": "58", + "natn": "0", + "etc_corp": "0", + "natfor": "1" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10059" + } + + # 요청 데이터 구성 + data = { + "dt": date, + "stk_cd": stock_code, + "amt_qty_tp": amount_quantity_type, + "trde_tp": trade_type, + "unit_tp": unit_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def aggregate_stock_data_by_investor_institution_request_ka10061( + self, + stock_code: str, + start_date: str, + end_date: str, + amount_quantity_type: str, + trade_type: str, + unit_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목별투자자기관별합계 요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + start_date (str): 시작일자 (YYYYMMDD) + end_date (str): 종료일자 (YYYYMMDD) + amount_quantity_type (str): 금액수량구분 (1:금액, 2:수량) + trade_type (str): 매매구분 (0:순매수, 1:매수, 2:매도) + unit_type (str): 단위구분 (1000:천주, 1:단주) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "stk_invsr_orgn_tot": [ + { + "ind_invsr": "--28837", + "frgnr_invsr": "--40142", + "orgn": "+64891", + "fnnc_invt": "+72584", + "insrnc": "--9071", + "invtrt": "--7790", + "etc_fnnc": "+35307", + "bank": "+526", + "penfnd_etc": "--22783", + "samo_fund": "--3881", + "natn": "0", + "etc_corp": "+1974", + "natfor": "+2114" + } + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10061" + } + + # 요청 데이터 구성 + data = { + "stk_cd": stock_code, + "strt_dt": start_date, + "end_dt": end_date, + "amt_qty_tp": amount_quantity_type, + "trde_tp": trade_type, + "unit_tp": unit_type + } + + return self._execute_request("POST", json=data, headers=headers) + + def today_vs_previous_day_execution_request_ka10084( + self, + stock_code: str, + today_or_previous: str, + tick_or_minute: str, + time: str = "", + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """당일전일체결 요청 + + Args: + stock_code (str): 종목코드 (예: "005930", "KRX:039490") + today_or_previous (str): 당일전일 (당일:1, 전일:2) + tick_or_minute (str): 틱분 (0:틱, 1:분) + time (str, optional): 조회시간 4자리 (예: 0900, 1430). Defaults to "". + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "tdy_pred_cntr": [ + { + "tm": "112711", + "cur_prc": "+128300", + "pred_pre": "+700", + "pre_rt": "+0.55", + "pri_sel_bid_unit": "-0", + "pri_buy_bid_unit": "+128300", + "cntr_trde_qty": "-1", + "sign": "2", + "acc_trde_qty": "2", + "acc_trde_prica": "0", + "cntr_str": "0.00" + }, + ... + ] + } + """ + # 헤더 구성 + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10084" + } + + # 요청 데이터 구성 + data = { + "stk_cd": stock_code, + "tdy_pred": today_or_previous, + "tic_min": tick_or_minute, + "tm": time + } + + return self._execute_request("POST", json=data, headers=headers) + + def watchlist_stock_information_request_ka10095( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """관심종목정보 요청 + + Args: + stock_code (str): 종목코드 (여러개 입력시 |로 구분) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "atn_stk_infr": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "+156600", + "base_pric": "121700", + "pred_pre": "+34900", + "pred_pre_sig": "2", + "flu_rt": "+28.68", + "trde_qty": "118636", + "trde_prica": "14889", + "cntr_qty": "-1", + "cntr_str": "172.01", + "pred_trde_qty_pre": "+1995.22", + "sel_bid": "+156700", + "buy_bid": "+156600", + ... + } + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10095" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def stock_information_list_request_ka10099( + self, + market_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목정보 리스트 요청 + + Args: + market_type (str): 시장구분 (0:코스피,10:코스닥,3:ELW,8:ETF,30:K-OTC,50:코넥스,5:신주인수권,4:뮤추얼펀드,6:리츠,9:하이일드) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "list": [ + { + "code": "005930", + "name": "삼성전자", + "listCount": "0000000123759593", + "auditInfo": "투자주의환기종목", + "regDay": "20091204", + "lastPrice": "00000197", + "state": "관리종목", + "marketCode": "10", + "marketName": "코스닥", + ... + } + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10099" + } + data = { + "mrkt_tp": market_type + } + return self._execute_request("POST", json=data, headers=headers) + + def stock_information_inquiry_request_ka10100( + self, + stock_code: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목정보 조회 요청 + + Args: + stock_code (str): 종목코드 (6자리) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "code": "005930", + "name": "삼성전자", + "listCount": "0000000026034239", + "auditInfo": "정상", + "regDay": "20090803", + "lastPrice": "00136000", + "state": "증거금20%|담보대출|신용가능", + "marketCode": "0", + "marketName": "거래소", + "upName": "금융업", + "upSizeName": "대형주", + "companyClassName": "", + "orderWarning": "0", + "nxtEnable": "Y", + "return_code": 0, + "return_msg": "정상적으로 처리되었습니다" + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10100" + } + data = { + "stk_cd": stock_code + } + return self._execute_request("POST", json=data, headers=headers) + + def industry_code_list_request_ka10101( + self, + market_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """산업코드 리스트 요청 + + Args: + market_type (str): 시장구분 (0:코스피, 1:코스닥, 2:KOSPI200, 4:KOSPI100, 7:KRX100) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "list": [ + { + "marketCode": "0", + "code": "001", + "name": "종합(KOSPI)", + "group": "1" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10101" + } + data = { + "mrkt_tp": market_type + } + return self._execute_request("POST", json=data, headers=headers) + + def member_company_list_request_ka10102( + self, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """회원사코드 리스트 요청 + + Args: + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "list": [ + { + "code": "001", + "name": "교 보", + "gb": "0" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka10102" + } + data = {} + return self._execute_request("POST", json=data, headers=headers) + + def top_50_program_buy_request_ka90003( + self, + trade_upper_type: str, + amount_quantity_type: str, + market_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """프로그램순매수상위50 요청 + + Args: + trade_upper_type (str): 매매상위구분 (1:순매도상위, 2:순매수상위) + amount_quantity_type (str): 금액수량구분 (1:금액, 2:수량) + market_type (str): 시장구분 (P00101:코스피, P10102:코스닥) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "prm_netprps_upper_50": [ + { + "rank": "1", + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "123000", + "flu_sig": "+", + "pred_pre": "+1000", + "flu_rt": "+0.82", + "acc_trde_qty": "1234567", + "prm_sell_amt": "1000000", + "prm_buy_amt": "2000000", + "prm_netprps_amt": "1000000" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90003" + } + data = { + "trde_upper_tp": trade_upper_type, + "amt_qty_tp": amount_quantity_type, + "mrkt_tp": market_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) + + def stock_wise_program_trading_status_request_ka90004( + self, + date: str, + market_type: str, + stock_exchange_type: str, + cont_yn: str = "N", + next_key: str = "" + ) -> Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: + """종목별 프로그램 매매상태 요청 + + Args: + date (str): 일자 (YYYYMMDD) + market_type (str): 시장구분 (P00101:코스피, P10102:코스닥) + stock_exchange_type (str): 거래소구분 (1:KRX, 2:NXT, 3:통합) + cont_yn (str, optional): 연속조회여부. Defaults to "N". + next_key (str, optional): 연속조회키. Defaults to "". + + Returns: + Union[Dict[str, Any], Awaitable[Dict[str, Any]]]: 응답 데이터 + { + "tot_1": "0", + "tot_2": "2", + "tot_3": "0", + "tot_4": "2", + "tot_5": "0", + "tot_6": "", + "stk_prm_trde_prst": [ + { + "stk_cd": "005930", + "stk_nm": "삼성전자", + "cur_prc": "-75000", + "flu_sig": "5", + "pred_pre": "-2800", + "buy_cntr_qty": "0", + "buy_cntr_amt": "0", + "sel_cntr_qty": "0", + "sel_cntr_amt": "0", + "netprps_prica": "0", + "all_trde_rt": "+0.00" + }, + ... + ] + } + """ + headers = { + "cont-yn": cont_yn, + "next-key": next_key, + "api-id": "ka90004" + } + data = { + "dt": date, + "mrkt_tp": market_type, + "stex_tp": stock_exchange_type + } + return self._execute_request("POST", json=data, headers=headers) diff --git a/kiwoom_rest_api/koreanstock/theme.py b/kiwoom_rest_api/koreanstock/theme.py new file mode 100644 index 0000000..b905bdb --- /dev/null +++ b/kiwoom_rest_api/koreanstock/theme.py @@ -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, + ) \ No newline at end of file diff --git a/kiwoom_rest_api/koreanstock/trading.py b/kiwoom_rest_api/koreanstock/trading.py new file mode 100644 index 0000000..b6c6d38 --- /dev/null +++ b/kiwoom_rest_api/koreanstock/trading.py @@ -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, + ) diff --git a/kiwoom_rest_api/trader.py b/kiwoom_rest_api/trader.py new file mode 100644 index 0000000..3885fa9 --- /dev/null +++ b/kiwoom_rest_api/trader.py @@ -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() \ No newline at end of file diff --git a/kiwoom_rest_api/websocket.py b/kiwoom_rest_api/websocket.py new file mode 100644 index 0000000..868ef61 --- /dev/null +++ b/kiwoom_rest_api/websocket.py @@ -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 \ No newline at end of file diff --git a/kiwoom_rest_api/websocket_constants.py b/kiwoom_rest_api/websocket_constants.py new file mode 100644 index 0000000..10d8c8c --- /dev/null +++ b/kiwoom_rest_api/websocket_constants.py @@ -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) \ No newline at end of file diff --git a/kiwoom_rest_api/websocket_helper.py b/kiwoom_rest_api/websocket_helper.py new file mode 100644 index 0000000..8701fb8 --- /dev/null +++ b/kiwoom_rest_api/websocket_helper.py @@ -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) \ No newline at end of file diff --git a/kiwoom_trader_dual.py b/kiwoom_trader_dual.py new file mode 100644 index 0000000..79ba420 --- /dev/null +++ b/kiwoom_trader_dual.py @@ -0,0 +1,1131 @@ +import time +import json +import datetime +import pandas as pd +import numpy as np +import os +import logging +import requests +import random +from dotenv import load_dotenv + +# ========================================================== +# [Step 0] 환경 변수 및 기본 설정 +# ========================================================== +current_dir = os.path.dirname(os.path.abspath(__file__)) +env_path = os.path.join(current_dir, ".env") +if not os.path.exists(env_path): + env_path = os.path.join(os.path.dirname(current_dir), ".env") + +load_dotenv(env_path) + +# Mattermost 설정 +MM_SERVER_URL = "https://mattermost.hoonfam.org" +MM_BOT_TOKEN = os.environ.get("MM_BOT_TOKEN_", "") +MM_CONFIG_FILE = os.path.join(current_dir, "mm_config.json") + +# [Logger 설정] - 로그 포맷 및 핸들러 설정 +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO +) +logger = logging.getLogger("JungleBot") + +# 외부 라이브러리 로그 레벨 조정 (너무 시끄러운 로그 방지) +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("requests").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) +logging.getLogger("httpcore").setLevel(logging.WARNING) + +# 키움 API 모듈 임포트 +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 + + +# ========================================================== +# [Part 0] 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): + """설정 파일에서 채널 ID 정보를 읽어옵니다.""" + 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", {}) + return {} + except Exception as e: + logger.error(f"⚠️ MM 설정 로드 실패: {e}") + return {} + + def send(self, channel_alias, message): + """지정된 채널(alias)로 메시지를 전송합니다.""" + channel_id = self.channels.get(channel_alias) + if not channel_id: + # 채널 ID가 없으면 로그만 찍고 넘어감 (봇 중단 방지) + logger.warning(f"❌ '{channel_alias}' 채널 ID 없음. mm_config.json 확인 필요.") + return False + + payload = {"channel_id": channel_id, "message": message} + try: + res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3) + res.raise_for_status() + return True + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + return False + + +# ========================================================== +# [Part 1] 데이터 클래스 (주식 정보 객체) +# ========================================================== +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 + + +# ========================================================== +# [Part 2] 브로커 API (키움증권 REST API 연동) +# ========================================================== +class BrokerAPI: + def __init__(self): + logger.info("🔵 키움(REST) 브로커 연결 시도...") + try: + self.token_manager = TokenManager() + self.stock_info = StockInfo(token_manager=self.token_manager) + self.chart = Chart(token_manager=self.token_manager) + self.order = Order(token_manager=self.token_manager) + self.rank = RankInfo(token_manager=self.token_manager) + self.account = Account(token_manager=self.token_manager) + self.market = MarketCondition(token_manager=self.token_manager) + self.acc_no = os.environ.get("KIWOOM_ACCOUNT_NO", "YOUR_ACCOUNT_NO") + logger.info(f"✅ 브로커 연결 완료 (계좌: {self.acc_no})") + except Exception as e: + logger.critical(f"❌ 브로커 초기화 실패: {e}") + raise e + + def _safe_request(self, func, *args, **kwargs): + """ + API 호출 안전장치 (타임아웃 및 429 에러 핸들링 강화) + - 429 에러(Too Many Requests) 발생 시 대기 후 재시도 + - 일반 에러 발생 시 로그 기록 + """ + full_name = func.__name__ + api_id = full_name.split('_')[-1] + max_retries = 3 + + for i in range(max_retries): + try: + # 기본 안전 대기 (API 과부하 방지) + time.sleep(1) + + result = func(*args, **kwargs) + + # 키움 API 특성상 200 OK라도 에러 메시지가 있을 수 있음 + if isinstance(result, dict) and result.get('return_code') != '0' and '초과' in result.get('msg1', ''): + raise Exception("429 Rate Limit Detected") + + return result + + except Exception as e: + # 429 또는 과부하 에러 시 더 오래 대기 + if "429" in str(e) or "과부하" in str(e): + logger.warning(f"⚠️ [{api_id}] API 과부하 감지 -> 5초 대기 후 재시도 ({i + 1}/{max_retries})") + time.sleep(5) + else: + logger.error(f"❌ [{api_id}] 호출 에러: {e}") + time.sleep(1) + + logger.error(f"💀 [{api_id}] 3회 재시도 실패 -> 빈 값 반환") + return {} + + def get_deposit_only(self): + """예수금(주문 가능 금액)만 빠르게 조회""" + try: + res = self._safe_request(self.account.deposit_detail_status_request_kt00001, qry_tp="2") + d2_deposit = float(res.get('d2_entra', 0)) if res else 0 + current_deposit = float(res.get('ord_alow_amt', 0)) if res else 0 + # D+2 예수금이 마이너스면 미수 발생 상황이므로 0으로 처리 + return 0 if d2_deposit < 0 else current_deposit + except Exception as e: + logger.error(f"예수금 조회 실패: {e}") + return 0 + + def get_intraday_investor(self, code): + """ + 장중 투자자별 매매 차트 (수급 확인용) + - 외국인/기관의 실시간 순매수 수량을 리턴 + """ + try: + res = self._safe_request(self.chart.intraday_investor_trading_chart_request_ka10064, + mrkt_tp="000", amt_qty_tp="2", trde_tp="0", stk_cd=code) + + if not res or 'opmr_invsr_trde_chart' not in res: + return 0, 0 + + data_list = res['opmr_invsr_trde_chart'] + if not data_list: + return 0, 0 + + latest = data_list[0] + foreigner = int(latest.get('frgnr_invsr', 0)) + institution = int(latest.get('orgn', 0)) + + return foreigner, institution + except Exception as e: + # 수급 조회 실패는 치명적이지 않으므로 0 리턴 + return 0, 0 + + def _is_target_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 check_market_status(self): + """ + 장 운영 시간 체크 (08:30 ~ 16:00) + - 주말 및 장운영 시간 외에는 False 반환 + """ + now = datetime.datetime.now() + + # 1. 시간 체크 (08:30 ~ 16:00) + if not (datetime.time(8, 30) <= now.time() <= datetime.time(16, 0)): + if now.minute == 0 and now.second < 2: + logger.info(f"💤 [장운영] 정규장 시간이 아닙니다. ({now.time().strftime('%H:%M:%S')})") + return False + + # 2. 주말 체크 + if now.weekday() >= 5: + if now.minute == 0 and now.second < 2: + logger.info("⏸️ [장운영] 주말 휴장입니다.") + return False + + # 공휴일 체크 로직은 제외 (Fail-Open 방식: 장이 안 열리면 주문 실패로 처리됨) + return True + + def get_multi_stock_data(self, code_list): + """여러 종목의 현재가 정보를 한 번에 조회""" + if not code_list: return {} + code_str = "|".join([str(c).strip() for c in code_list]) + + try: + res = self._safe_request(self.stock_info.watchlist_stock_information_request_ka10095, stock_code=code_str) + result_map = {} + if res and 'atn_stk_infr' in res: + for item in res['atn_stk_infr']: + code = item.get('stk_cd', '') + if not code: continue + if len(code) > 6: code = code[-6:] + try: + c = abs(float(item['cur_prc'])) + if c > 0: + result_map[code] = { + 'code': code, 'name': item['stk_nm'], + 'price': c, 'open': abs(float(item['open_pric'])), + 'high': abs(float(item['high_pric'])), 'low': abs(float(item['low_pric'])) + } + except: + continue + return result_map + except Exception as e: + logger.error(f"멀티 시세 조회 실패: {e}") + return {} + + def get_ohlcv_limit(self, code, timeframe='1m'): + """분봉 차트 데이터 조회 (틱 범위 지정 가능)""" + tic_scope = "1" + if timeframe == '3m': + tic_scope = "3" + elif timeframe == '5m': + tic_scope = "5" + elif timeframe == '10m': + tic_scope = "10" + + try: + res = self._safe_request(self.chart.stock_minute_chart_request_ka10080, stk_cd=code, tic_scope=tic_scope, + upd_stkpc_tp="1") + data = res.get('stk_min_pole_chart_qry', []) if res else [] + if not data: return pd.DataFrame() + + df = pd.DataFrame(data) + df = df.rename(columns={'cur_prc': 'close', 'open_pric': 'open', 'high_pric': 'high', 'low_pric': 'low', + 'trde_qty': 'volume'}) + # 데이터를 시간순(과거->현재)으로 정렬 + return df[['open', 'high', 'low', 'close', 'volume']].astype(float).abs().iloc[::-1].reset_index(drop=True) + except Exception as e: + logger.error(f"차트 조회 실패({code}): {e}") + return pd.DataFrame() + + def scan_ant_shaking_candidates(self, max_price_limit=None): + """ + [조건검색 대체 로직] 개미털기(눌림목) 후보 종목 스캔 + - 거래대금 및 회전율 상위 종목 중, 고점 대비 일정 비율 하락 후 반등 시도하는 종목 추출 + """ + logger.info(f"🐜 [개미털기] 스캔 시작") + raw_codes_set = set() + scan_strategies = [("3", "거래대금"), ("2", "회전율")] + + for sort_tp, desc in scan_strategies: + 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: continue + + for stock in res['tdy_trde_qty_upper']: + code = stock['stk_cd'].split('_')[0] + try: + price = abs(float(stock['cur_prc'])) + except: + continue + + # 전일 종가 조회 (API 함수 확인 필요) + open_price = abs(float(stock.get('open_pric', price))) + change_rate = ((price - open_price) / open_price * 100) if open_price > 0 else 0 + + if price < 1000: continue # 동전주 제외 + + if change_rate > 20: # 상한가 근처 + logger.info(f"🚫 상한가 제외: {stock['stk_nm']} (+{change_rate:.1f}%)") + continue + if price > 200000: continue # 10만원 이상 제외 + if self._is_target_stock(stock['stk_nm'], 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] + multi_data = self.get_multi_stock_data(chunk) + time.sleep(0.5) + + for code, data in multi_data.items(): + if max_price_limit and data['price'] > max_price_limit: continue + + op, hi, lo, cl = data['open'], data['high'], data['low'], data['price'] + if op == 0: continue + + drop_rate = (op - lo) / op + total_range = hi - lo + + # 낙폭이 어느 정도 있고(3% 이상), 아래꼬리를 달고 올라온 종목 + if total_range > 0 and drop_rate > 0.03: + recovery_pos = (cl - lo) / total_range + if recovery_pos > 0.5: + score = drop_rate * 100 + final_list.append({'code': code, 'name': data['name'], 'price': cl, 'score': score}) + + final_list.sort(key=lambda x: x['score'], reverse=True) + return final_list + except Exception as e: + logger.error(f"분석 중 치명적 에러: {e}") + return [] + + def get_account_info(self): + """ + 전체 계좌 잔고 및 평가금액 조회 + - 예수금, 총 자산, 보유 종목 리스트 반환 + """ + try: + # 1. 예수금 상세 + res = self._safe_request(self.account.deposit_detail_status_request_kt00001, qry_tp="2") + d2_deposit = float(res.get('d2_entra', 0)) if res else 0 + current_deposit = float(res.get('ord_alow_amt', 0)) if res else 0 + deposit = 0 if d2_deposit < 0 else current_deposit + logger.info(f"예수금: {deposit:,.0f}원") + + # 2. 잔고 상세 + res_bal = self._safe_request(self.account.account_evaluation_balance_detail_request_kt00018, query_type="1", + domestic_exchange_type="KRX") + total_asset = float(res_bal.get('tot_aset_amt', 0)) if res_bal else 0 + + balances = {} + if res_bal and 'acnt_evlt_remn_indv_tot' in res_bal: + for item in res_bal['acnt_evlt_remn_indv_tot']: + code = item['stk_cd'].strip()[1:] if item['stk_cd'].startswith('A') else item['stk_cd'].strip() + balances[code] = { + 'buy_price': abs(float(item['pur_pric'])), + 'qty': int(item['rmnd_qty']), + 'name': item['stk_nm'].strip(), + 'current_price': abs(float(item['cur_prc'])), + 'profit_rate': float(item.get('erng_rt', 0)) + } + + # API에서 총자산이 0으로 올 경우, 직접 계산 + if total_asset == 0: + stock_val = sum([b['current_price'] * b['qty'] for b in balances.values()]) + total_asset = current_deposit + stock_val + + return total_asset, deposit, balances + except Exception as e: + logger.error(f"계좌 조회 에러: {e}") + return 0, 0, {} + + def get_current_data(self, code): + """단일 종목 현재가 조회""" + try: + res = self._safe_request(self.stock_info.watchlist_stock_information_request_ka10095, stock_code=code) + if res and 'atn_stk_infr' in res and len(res['atn_stk_infr']) > 0: + item = res['atn_stk_infr'][0]; + p = abs(float(item.get('cur_prc', 0))) + return StockData(code, item.get('stk_nm', 'Unknown'), p, abs(float(item.get('open_pric', p))), + abs(float(item.get('high_pric', p))), abs(float(item.get('low_pric', p))), p, + int(item.get('trde_qty', 0))) + return None + except Exception as e: + logger.error(f"현재가 조회 에러({code}): {e}") + return None + + def buy_market_order(self, code, qty): + """시장가 매수 주문""" + try: + res = self._safe_request(self.order.stock_buy_order_request_kt10000, dmst_stex_tp="KRX", stk_cd=code, + ord_qty=str(qty), trde_tp="3", ord_uv="0") + if str(res.get('return_code')) == '0': return True + logger.error(f"매수 주문 실패({code}): {res}") + return False + except Exception as e: + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문""" + try: + res = self._safe_request(self.order.stock_sell_order_request_kt10001, dmst_stex_tp="KRX", stk_cd=code, + ord_qty=str(qty), trde_tp="3", ord_uv="0") + if str(res.get('return_code')) == '0': return True + logger.error(f"매도 주문 실패({code}): {res}") + return False + except Exception as e: + logger.error(f"매도 주문 예외({code}): {e}") + return False + + +# ========================================================== +# [Part 3] 정글 서바이버 봇 (개선된 메인 로직) +# ========================================================== +class JungleSurvivorBot: + def __init__(self, broker_api, budget=None): + self.api = broker_api + self.budget = budget + + # Mattermost 초기화 + self.mm = MattermostBot() + self.mm_channel = "stock" # 기본 채널 alias + + # 파일 경로 설정 + self.portfolio_file = os.path.join(current_dir, 'portfolio.json') + self.history_real_file = os.path.join(current_dir, 'trade_history_real.json') + self.target_file = os.path.join(current_dir, 'target_universe.json') + self.banned_file = os.path.join(current_dir, 'banned_codes.json') + + # 자금 관리 설정 + self.max_stocks = 5 + self.slot_money = 0 + self.daily_stop_loss_pct = -0.05 # 일일 손실 한도 -5% + self.stop_loss_pct = -0.035 # 종목별 손절 -3.5% + self.enable_consecutive_loss_cut = False # 연속 손절 제한 (일단 끔) + + # 매수/매도 로직 파라미터 + self.rsi_overheat_threshold = float(os.environ.get("RSI_OVERHEAT_THRESHOLD", "73")) # RSI 과열 기준 + self.shoulder_cut_pct = float(os.environ.get("SHOULDER_CUT_PCT", "0.03")) # 어깨 매도 기준 (고점 대비 하락률) + self.high_price_chase_threshold = float( + os.environ.get("HIGH_PRICE_CHASE_THRESHOLD", "0.96")) # 일일 최고가 추격 매수 방지 기준 + self.min_recovery_ratio = float(os.environ.get("MIN_RECOVERY_RATIO", "0.5")) # 꼬리 잡기 최소 회복 비율 + self.max_recovery_ratio = float(os.environ.get("MAX_RECOVERY_RATIO", "0.8")) # 꼬리 잡기 최대 회복 비율 + self.candle_open_price_buffer = float(os.environ.get("CANDLE_OPEN_PRICE_BUFFER", "0.995")) # 시가 대비 현재가 버퍼 + self.volume_avg_multiplier = float(os.environ.get("VOLUME_AVG_MULTIPLIER", "1.0")) # 거래량 평균 대비 배율 + self.intraday_investor_net_buy_threshold = int(os.environ.get("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000")) # 장중 투자자 순매수 기준 + + self.stop_atr_multiplier_tail = float(os.environ.get("STOP_ATR_MULTIPLIER_TAIL", "3.5")) # TAIL_CATCH_3M 전략용 (2일 보유 전제) + self.target_atr_multiplier_tail = float(os.environ.get("TARGET_ATR_MULTIPLIER_TAIL", "8.0")) # TAIL_CATCH_3M 전략용 (2일 보유 전제) + self.stop_atr_multiplier_normal = float(os.environ.get("STOP_ATR_MULTIPLIER_NORMAL", "2.5")) # 일반 전략용 + self.target_atr_multiplier_normal = float(os.environ.get("TARGET_ATR_MULTIPLIER_NORMAL", "5.0")) # 일반 전략용 + + # 상태 변수 초기화 + self.current_cash = 0 + self.start_of_day_asset = 0 + self.today_date = datetime.datetime.now().strftime("%Y%m%d") + self.consecutive_losses = 0 + self.trading_halted = False + self.was_market_open = False + self.is_first_run = True + + # 초기 데이터 로드 및 정리 + self.refresh_account_status(is_init=True) + self.cleanup_banned_list() + + msg = f"🤖 **[정글봇 가동-Full Version]**\n- 시작 자산: {self.start_of_day_asset:,.0f}원\n- **[NEW] 3% 하락시 어깨 매도 적용**\n- **[NEW] RSI 과열 필터 적용**" + logger.info(msg) + self.send_mm(msg) + + def send_mm(self, msg): + """Mattermost 알림 전송 래퍼 함수""" + try: + self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 중 에러: {e}") + + # ====================================================== + # 파일 입출력 및 관리 유틸리티 + # ====================================================== + def load_json_file(self, path, is_list=False): + try: + if os.path.exists(path): + if os.path.getsize(path) == 0: + logger.warning(f"⚠️ 빈 파일 감지됨(초기화): {os.path.basename(path)}") + return [] if is_list else {} + with open(path, 'r', encoding='utf-8') as f: + return json.load(f) + return [] if is_list else {} + except json.JSONDecodeError: + logger.warning(f"⚠️ JSON 형식이 깨짐(초기화): {os.path.basename(path)}") + return [] if is_list else {} + except Exception as e: + logger.error(f"❌ load_json_file 에러: {e}") + return [] if is_list else {} + + def save_json_file(self, path, data): + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=4) + except Exception as e: + logger.error(f" 파일저장에러 {e}") + + def get_banned_codes(self): + """금일 매매 금지(손절 등) 종목 리스트 반환""" + data = self.load_json_file(self.banned_file) + active = [] + now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + for c, e in data.items(): + if e > now: active.append(c) + return active + + def add_ban(self, code, hours=24): + """종목을 벤(Ban) 리스트에 추가""" + data = self.load_json_file(self.banned_file) + data[code] = (datetime.datetime.now() + datetime.timedelta(hours=hours)).strftime('%Y-%m-%d %H:%M:%S') + self.save_json_file(self.banned_file, data) + + def cleanup_banned_list(self): + """만료된 벤 리스트 정리""" + data = self.load_json_file(self.banned_file) + now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + new_data = {k: v for k, v in data.items() if v > now} + if len(data) != len(new_data): self.save_json_file(self.banned_file, new_data) + + # ====================================================== + # 계좌 및 자산 관리 + # ====================================================== + def refresh_account_status(self, is_init=False): + """계좌 상태를 API로부터 갱신하고 슬롯 머니를 재계산""" + try: + curr_date = datetime.datetime.now().strftime("%Y%m%d") + if curr_date != self.today_date: + self.today_date = curr_date + self.consecutive_losses = 0 + self.trading_halted = False + self.start_of_day_asset = 0 + logger.info("📅 [날짜변경] 데이터 리셋") + + total, deposit, balances = self.api.get_account_info() + + if total == 0: + logger.warning("⚠️ 자산 조회 실패(0원) -> 기존 자산 유지") + total = self.current_total_asset if hasattr(self, 'current_total_asset') else 1000000 + + if self.start_of_day_asset == 0 or is_init: + self.start_of_day_asset = total + + self.current_total_asset = total + self.current_cash = min(self.budget, deposit) if self.budget else deposit + + # 현재 보유 주식의 대략적인 평가금액 (예수금과의 갭 확인용) + try: + stock_val = sum( + [b.get('current_price', 0) * b.get('qty', 0) for b in balances.values()] + ) if balances else 0 + except Exception: + stock_val = 0 + + # 예수금의 90%만 사용 (안전 버퍼) + investable = self.current_cash * 0.9 + min_bet = 100000 + + if investable < min_bet: + self.max_stocks = 1 + self.slot_money = int(investable) + else: + if (investable / 30) < min_bet: + self.max_stocks = max(int(investable / min_bet), 1) + else: + self.max_stocks = 30 + self.slot_money = int(investable / self.max_stocks) + + if is_init: + logger.info( + f"💰 [자금 설정] " + f"추정자산: {self.current_total_asset:,.0f}원 " + f"(예수금: {self.current_cash:,.0f}원 + 주식평가: {stock_val:,.0f}원) | " + f"1종목당: {self.slot_money:,.0f}원" + ) + + self.portfolio = self.sync_portfolio_internal(balances) + self.check_risk_status(total) + + except Exception as e: + logger.error(f"계좌갱신오류: {e}") + + def update_account_light(self, profit_val=0): + """ + API 부하를 줄이기 위해 예수금만 빠르게 갱신하고 + 총자산은 로컬 계산으로 추정하는 경량 함수 + """ + try: + new_cash = self.api.get_deposit_only() + if new_cash > 0 or self.current_cash == 0: + self.current_cash = new_cash + + if profit_val != 0: + self.current_total_asset += profit_val + except Exception as e: + logger.error(f"경량 갱신 실패: {e}") + + def get_daily_profit_rate(self, current_asset): + if self.start_of_day_asset == 0: return 0.0 + return (current_asset - self.start_of_day_asset) / self.start_of_day_asset + + def check_risk_status(self, current_asset): + """일일 손실 한도 체크""" + p = self.get_daily_profit_rate(current_asset) + + if p <= self.daily_stop_loss_pct and not self.trading_halted: + self.trading_halted = True + msg = f"🛑 **[STOP LOSS 발동]**\n일 손실률 {p * 100:.2f}% 도달 -> 금일 매수 중단" + logger.critical(msg) + self.send_mm(msg) + + if self.enable_consecutive_loss_cut and self.consecutive_losses >= 4 and not self.trading_halted: + self.trading_halted = True + msg = f"🛑 **[연속 손절 과다]**\n4회 연속 손절 -> 금일 매수 중단" + logger.critical(msg) + self.send_mm(msg) + + def sync_portfolio_internal(self, real_balances): + """API 잔고와 로컬 포트폴리오 파일 동기화""" + local = self.load_json_file(self.portfolio_file) + final = {} + + # 1. 실제 잔고 기준 업데이트 + for c, r in real_balances.items(): + final[c] = r + if c in local: + final[c].update({ + 'strategy': local[c].get('strategy', 'MANUAL'), + 'max_price': max(r['current_price'], local[c].get('max_price', 0)), + 'target_price': local[c].get('target_price', 0), + 'stop_price': local[c].get('stop_price', 0), + 'atr_at_entry': local[c].get('atr_at_entry', 0), + 'buy_date': local[c].get('buy_date', '') + }) + else: + final[c].update({'strategy': 'MANUAL', 'max_price': r['current_price']}) + + # 데이터 복구 (봇 재시작 시 누락된 전략 정보 복원) + if final[c].get('atr_at_entry') == 0 or final[c].get('strategy') == 'MANUAL': + try: + df_temp = self.api.get_ohlcv_limit(c, timeframe='1m') + if not df_temp.empty: + new_atr = self.calculate_atr(df_temp) + base_p = r.get('buy_price', r['current_price']) + final[c].update({ + 'strategy': 'RECOVERED', + 'atr_at_entry': new_atr, + 'stop_price': base_p - (new_atr * 3.0), + 'target_price': base_p + (new_atr * 5.0), + 'buy_date': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + }) + logger.info(f"♻️ [데이터 복구] {r['name']} 전략 정보 재계산 완료") + except Exception as e: + logger.error(f"❌ {c} 데이터 복구 실패: {e}") + + # 2. 동기화 보호 (매수 직후 API 반영 지연 시 로컬 데이터 우선) + now = datetime.datetime.now() + for code, info in local.items(): + if code not in final: + try: + buy_time = datetime.datetime.strptime(info.get('buy_date', ''), '%Y-%m-%d %H:%M:%S') + if (now - buy_time).total_seconds() < 120: + final[code] = info + logger.info(f"🛡️ [동기화 보호] {info['name']}: 잔고 미반영 유지") + except: + pass + + self.save_json_file(self.portfolio_file, final) + return final + + def update_universe(self): + """매수 후보군 업데이트""" + logger.info(f"🔄 [리스트 갱신] 배정액 {self.slot_money:,.0f}원") + candidates = self.api.scan_ant_shaking_candidates(max_price_limit=self.slot_money) + if not candidates: return + + candidates.sort(key=lambda x: (x['score'], x['price']), reverse=True) + current_data = [] + for item in candidates: + current_data.append( + {"code": item['code'], "name": item['name'], "score": item['score'], "price": item['price']}) + + self.save_json_file(self.target_file, current_data) + + top_picks = [f"{x['name']}({x['score']:.1f})" for x in current_data[:5]] + logger.info(f" 🔝 Top 5: {', '.join(top_picks)}") + + # ====================================================== + # 기술적 지표 계산 + # ====================================================== + def calculate_atr(self, df, period=14): + """ATR(Average True Range) 계산""" + df['tr'] = np.maximum(df['high'] - df['low'], + np.maximum(np.abs(df['high'] - df['close'].shift()), + np.abs(df['low'] - df['close'].shift()))) + return df['tr'].rolling(window=period).mean().iloc[-1] + + def calculate_rsi(self, series, period=14): + """RSI(Relative Strength Index) 계산""" + delta = series.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + return 100 - (100 / (1 + gain / loss)) + + # ========================================================= + # ⚡ [핵심 수정 1] 매수 시그널 - RSI 필터 및 고점 추격 방지 + # ========================================================= + def check_buy_signal(self, code): + """ + 매수 시그널 확인 함수 (3분봉 꼬리잡기 전략) + - RSI 과열 체크 + 고가 추격 방지 + 꼬리 필터 강화 + """ + # ⭐ name 변수 먼저 초기화 (에러 방지) + name = code + + # 1. 밴(Ban) 리스트 확인 + if code in self.get_banned_codes(): + return None + + try: + # 2. 데이터 조회 + df = self.api.get_ohlcv_limit(code, timeframe='3m') + curr = self.api.get_current_data(code) + + # name 업데이트 + if curr: + name = curr.name + + # 데이터 유효성 검사 + if curr is None or df is None or len(df) < 20: + logger.info(f"🔍 [데이터 부족] {name} {code}: DF 길이 {len(df) if df is not None else 0}") + return None + + # 3. 지표 계산 + ma20 = df['close'].rolling(window=20).mean().iloc[-1] + avg_vol = df['volume'].rolling(window=20).mean().iloc[-1] + current_price = curr.current_price + current_vol = df['volume'].iloc[-1] + + # --------------------------------------------------------- + # [필터 1] RSI 과열 체크 + rsi = self.calculate_rsi(df['close']).iloc[-1] + if rsi >= self.rsi_overheat_threshold: + return None + + # [필터 2] 일일 최고가 부근 추격 매수 방지 + if current_price >= curr.high * self.high_price_chase_threshold: + return None + + # [필터 3] 20일 이동평균선(MA20) 아래인지? + if current_price < ma20: + logger.info(f"🔍 [Pass-MA20] {name} {code}: 현재가({current_price}) < MA20({ma20:.2f})") + return None + + # [필터 4] 최소 거래량 필터 (평균의 30%도 안되면 패스) + if current_vol < avg_vol * 0.3: + logger.info(f"🔍 [Pass-Vol] {name} {code}: 거래량 부족 ({current_vol} < {avg_vol * 0.3:.1f})") + return None + + # --------------------------------------------------------- + # [타점 분석] 3분봉 꼬리 잡기 로직 + candle_open = df['open'].iloc[-1] + candle_low = min(df['low'].iloc[-1], current_price) + total_tail = candle_open - candle_low + + if total_tail <= 0: + return None + + # 회복 비율 계산 + recovery_ratio = (current_price - candle_low) / total_tail + + # --- 로그로 상세 수치 확인 --- + log_msg = ( + f"🧐 분석({name} {code}): 가격{current_price} | MA20 {ma20:.1f} | " + f"회복률 {recovery_ratio:.2f} (조건:{self.min_recovery_ratio}~{self.max_recovery_ratio}) | " + f"거래량 {current_vol} (조건:>{avg_vol * self.volume_avg_multiplier:.1f})" + ) + + # [조건 1] 회복 탄력성 (환경변수 사용: 기본 0.5~0.8) + if not (self.min_recovery_ratio <= recovery_ratio <= self.max_recovery_ratio): + logger.info(f"{log_msg} -> ❌ 회복률 미달/초과") + return None + + # [조건 2] 시가 근접성 (환경변수 사용: 기본 99.5%) + if current_price < candle_open * self.candle_open_price_buffer: + logger.info(f"{log_msg} -> ❌ 시가 회복 부족") + return None + + # [조건 3] 거래량 폭발 여부 (환경변수 사용: 기본 평균 × 1.0) + if current_vol <= avg_vol * self.volume_avg_multiplier: + logger.info(f"{log_msg} -> ❌ 거래량 파워 부족") + return None + + # --------------------------------------------------------- + # [수급 필터] 최종 관문 + foreigner, institution = self.api.get_intraday_investor(code) + + logger.info(f"✨ 1차 통과({name} {code}): 수급 확인 중... 외인:{foreigner}, 기관:{institution}") + + # 양쪽에서 동시에 대량 매도 중이면 스킵 (환경변수 사용: 기본 -1000) + if foreigner < self.intraday_investor_net_buy_threshold and institution < self.intraday_investor_net_buy_threshold: + logger.info(f"⛔ 수급 이탈 감지({name} {code}): 외인{foreigner} / 기관{institution} -> 매수 포기") + return None + + # 최종 매수 시그널 + logger.info(f"🚀 [매수 신호] {name} {code}: 3분봉 꼬리 공략! (회복률: {recovery_ratio:.2f})") + return 'TAIL_CATCH_3M' + + except Exception as e: + logger.error(f"⚠️ 매수 시그널 분석 중 에러({name} {code}): {e}", exc_info=True) + return None + + def execute_buy(self, code, strategy): + """매수 실행 및 포트폴리오 등록""" + if self.trading_halted: return + if self.current_cash < self.slot_money: return + + curr = self.api.get_current_data(code) + if not curr or curr.current_price > self.slot_money: return + + # ATR 계산 및 손절/목표가 설정 + df = self.api.get_ohlcv_limit(code, timeframe='1m') + atr = curr.current_price * 0.01 + if len(df) >= 20: atr = self.calculate_atr(df) + + # stop = curr.current_price - (atr * 2.0) + # target = curr.current_price + (atr * 3.0) + if strategy == 'TAIL_CATCH_3M': + # 2일 보유 전제: 손절 넓게, 목표가 크게 + stop = curr.current_price - (atr * self.stop_atr_multiplier_tail) + target = curr.current_price + (atr * self.target_atr_multiplier_tail) + else: + stop = curr.current_price - (atr * self.stop_atr_multiplier_normal) + target = curr.current_price + (atr * self.target_atr_multiplier_tail) + + # 분할 매수 실행 + total_qty = int(self.slot_money / curr.current_price) + if total_qty > 0: + split_count = 1 + if self.slot_money >= 100000: + split_count = random.randint(10, 20) + + base_qty = total_qty // split_count + remainder = total_qty % split_count + bought_qty = 0 + + logger.info(f"🔫 [진입 시작] {curr.name} 총 {total_qty}주 | {split_count}회 분할") + + for i in range(split_count): + qty_to_buy = base_qty + (remainder if i == split_count - 1 else 0) + if qty_to_buy <= 0: continue + + if self.api.buy_market_order(code, qty_to_buy): + bought_qty += qty_to_buy + if i < split_count - 1: + time.sleep(random.uniform(0.8, 1)) + else: + logger.error(f"❌ 매수 주문 실패: {curr.name}") + + # 매수 성공 시 처리 + if bought_qty > 0: + self.portfolio = self.load_json_file(self.portfolio_file) + now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + + self.portfolio[code] = { + 'buy_price': curr.current_price, + 'qty': bought_qty, + 'strategy': strategy, + 'buy_date': now_str, + 'name': curr.name, + 'stop_price': stop, + 'target_price': target, + 'atr_at_entry': atr, + 'max_price': curr.current_price # 초기값은 매수단가 + } + + self.save_json_file(self.portfolio_file, self.portfolio) + self.add_history('BUY', code, curr.name, curr.current_price, bought_qty, strategy) + + # 알림 + try: + self.update_account_light(profit_val=0) + total_p = self.get_daily_profit_rate(self.current_total_asset) * 100 + msg = f"🔫 **[매수 완료] {curr.name}**\n수량: {bought_qty}주\n전략: {strategy}\n자산변동: {total_p:+.2f}%" + self.send_mm(msg) + except Exception as e: + logger.error(f"알림 전송 실패: {e}") + + time.sleep(0.5) + + # ========================================================= + # ⚡ [핵심 수정 2] 매도 로직 - 어깨 매도 및 거래량 체크 추가 + # ========================================================= + def check_sell(self, code): + """매도 조건 체크 (트레일링 스탑, 손절, 어깨 매도)""" + if code not in self.portfolio: return False, None, 0, 0 + + info = self.portfolio[code] + curr = self.api.get_current_data(code) + if not curr: return False, None, 0, 0 + + buy_price = info.get('buy_price', 0) + current_price = curr.current_price + + # 고점(Max Price) 갱신 + if current_price > info.get('max_price', 0): + info['max_price'] = current_price + self.save_json_file(self.portfolio_file, self.portfolio) + + max_price = info.get('max_price', buy_price) + profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0 + profit_val = (current_price - buy_price) * info['qty'] + atr = info.get('atr_at_entry', buy_price * 0.01) + reason = None + + # [필수 1] 어깨 매도 (Shoulder Cut) + # 고점 대비 3% 이상 빠지면 수익/손실 여부 불문하고 즉시 탈출 + # (선익시스템 사례 방지: 고점에서 물렸을 때 빠르게 자르기 위함) + drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0 + if drop_from_high >= self.shoulder_cut_pct: + # 추가 체크: 거래량이 터지면서 떨어지면 더 위험하지만, 어깨 매도는 무조건 자르는게 원칙 + return True, f"어깨매도(고점대비-{drop_from_high * 100:.1f}%)", profit_pct, profit_val + + # 기존 로직 분기 + if info.get('strategy') == 'TAIL_CATCH_3M': + # 스캘핑 로직 + if (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2): + reason = "스캘핑_본절사수" + elif current_price < (max_price - atr * 1.0) and profit_pct > 0: + reason = "스캘핑_익절보존" + + # 시간 컷 (10분 내 승부 안나면 매도) + buy_time_str = info.get('buy_date', '') + hours_passed = 0 + if buy_time_str: + buy_time = datetime.datetime.strptime(buy_time_str, '%Y-%m-%d %H:%M:%S') + hours_passed = (datetime.datetime.now() - buy_time).total_seconds() / 3600 + + # [전략별 분기] + if info.get('strategy') == 'TAIL_CATCH_3M': + # 2일(48시간) 이내는 보수적 보유 + if hours_passed < 48: + # 큰 수익만 익절 + if profit_pct > 0.05: + reason = "💰 2일내 5%+ 익절" + elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97: + reason = "📈 2일내 고점7% 찍고 3% 하락" + else: + # 2일 경과 후 적극 익절 + if profit_pct > 0.02: + reason = "⏰ 2일 경과 2%+ 익절" + elif profit_pct > 0 and current_price < max_price * 0.97: + reason = "⏰ 2일 경과 익절보호" + + else: + # 일반/MANUAL/RECOVERED 전략 + max_profit_pct = (max_price - buy_price) / buy_price if buy_price > 0 else 0 + if max_profit_pct >= 0.015 and profit_pct <= 0.005: + reason = "본절보호" + elif profit_pct > 0 and max_profit_pct >= 0.03 and current_price < max_price * 0.99: + reason = "트레일링스탑" + + # [공통] 최후의 보루 (목표가 달성 및 손절) + if not reason: + if current_price >= info.get('target_price', 9999999): + reason = "목표달성" + elif profit_pct <= self.stop_loss_pct: + reason = f"칼손절({profit_pct * 100:.1f}%)" + self.add_ban(code) + self.consecutive_losses += 1 + elif current_price <= info.get('stop_price', 0): + reason = "전략손절" + + if reason: + if profit_pct > 0: self.consecutive_losses = 0 + return True, reason, profit_pct, profit_val + + return False, None, 0, 0 + + def add_history(self, type, code, name, price, qty, reason="", strategy=""): + """매매 이력 저장""" + try: + rec = { + 'type': type, + 'time': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), + 'code': code, + 'name': name, + 'price': price, + 'qty': qty, + 'reason': reason, + 'strategy': strategy + } + h = self.load_json_file(self.history_real_file, is_list=True) + h.append(rec) + with open(self.history_real_file, 'w', encoding='utf-8') as f: + json.dump(h, f, ensure_ascii=False, indent=4) + except: + pass + + def run(self): + """메인 실행 루프""" + logger.info(f"🚀 감시 시작 (손절: {self.stop_loss_pct * 100}%)") + + # 봇 시작 시 장 상태 확인 및 알림 + first_check = self.api.check_market_status() + if not first_check: + self.send_mm("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.") + + while True: + # 장 상태 체크 + is_open = self.api.check_market_status() + + # 장 시작/마감 이벤트 처리 + if is_open and not self.was_market_open: + self.refresh_account_status() + self.was_market_open = True + self.send_mm("🌅 **[장 시작]** 봇이 매매를 시작합니다.") + + elif not is_open and self.was_market_open: + self.was_market_open = False + self.refresh_account_status() + day_profit = self.current_total_asset - self.start_of_day_asset + self.send_mm(f"🌙 **[장 마감]**\n오늘 손익: {day_profit:,.0f}원") + self.is_first_run = True # 다음 날을 위해 리셋 + + # 장 휴장 시 대기 + if not is_open: + time.sleep(60) + continue + + try: + now = datetime.datetime.now() + + # [유니버스 갱신] 5분 주기 + if self.is_first_run or (now.minute % 5 == 0 and now.second < 5): + self.update_universe() + self.cleanup_banned_list() + self.is_first_run = False + logger.info("⏳ [주기] 유니버스 갱신 완료, 5초 대기 후 다음 루프") + time.sleep(5) + + # [생존 신고] 1분 주기 (로그만) + if now.minute % 1 == 0 and now.second < 2: + targets = self.load_json_file(self.target_file, is_list=True) + logger.info(f"👀 [생존] 타겟:{len(targets)} | 보유:{len(self.portfolio)}/{self.max_stocks} | 2초 대기") + time.sleep(2) + + # 1. 매도 로직 (우선 순위) + for code in list(self.portfolio.keys()): + sell, reason, profit_pct, profit_val = self.check_sell(code) + if sell: + info = self.portfolio[code] + if self.api.sell_market_order(code, info['qty']): + del self.portfolio[code] + self.save_json_file(self.portfolio_file, self.portfolio) + + strategy_tag = info.get('strategy', '') + self.add_history('SELL', code, info['name'], 0, info['qty'], reason, strategy_tag) + + # 알림 전송 + try: + self.update_account_light(profit_val) + total_p = self.get_daily_profit_rate(self.current_total_asset) * 100 + icon = "💰" if profit_pct > 0 else "💧" + r_tag = "[RECOVERED] " if strategy_tag == "RECOVERED" else "" + + msg = f"{icon} **[매도] {r_tag}{info['name']}**\n수익: {profit_pct * 100:.2f}% ({profit_val:,.0f}원)\n사유: {reason}\n누적: {total_p:+.2f}%" + self.send_mm(msg) + except Exception as e: + logger.error(f"매도 알림 실패: {e}") + + time.sleep(1) + + # 2. 매수 로직 + if not self.trading_halted and len(self.portfolio) < self.max_stocks: + targets = self.load_json_file(self.target_file, is_list=True) + if targets: + for item in targets: + if item['code'] in self.portfolio: continue + + # 돈 없으면 스탑 + if self.current_cash < self.slot_money: break + + # 매수 시그널 확인 + sig = self.check_buy_signal(item['code']) + if sig: + self.execute_buy(item['code'], sig) + time.sleep(1) + + time.sleep(1) + + except KeyboardInterrupt: + logger.info("🛑 사용자에 의해 종료됨") + break + except Exception as e: + logger.error(f"메인 루프 에러: {e}") + self.send_mm(f"⚠️ **[봇 에러 발생]**\n내용: {e}") + logger.info("⏳ [복구] 에러 후 5초 대기 후 재개") + time.sleep(5) + + +if __name__ == "__main__": + bot = JungleSurvivorBot(BrokerAPI()) + bot.run() \ No newline at end of file diff --git a/kiwoom_trader_ver2.py b/kiwoom_trader_ver2.py index f07ee0c..79a19b5 100644 --- a/kiwoom_trader_ver2.py +++ b/kiwoom_trader_ver2.py @@ -1,1045 +1,3179 @@ -""" -Kiwoom Trading Bot Ver2 - DB 기반 고급 트레이딩 시스템 -- SQLite DB 기반 안전한 데이터 관리 -- 변동성 기반 자금 관리 (Risk Manager) -- TWAP 스마트 분할 매수 (Smart Executor) -- 하프 켈리 공식 적용 -- 기존 TAIL_CATCH_3M 전략 유지 및 강화 -""" - -import time -import json -import datetime -import pandas as pd -import numpy as np -import os -import logging -import requests -from dotenv import load_dotenv - -# 새로운 모듈 임포트 -from database import TradeDB -from risk_manager import RiskManager -from smart_executor import SmartOrderExecutor - -# ========================================================== -# [Step 0] 환경 변수 및 기본 설정 -# ========================================================== -current_dir = os.path.dirname(os.path.abspath(__file__)) -env_path = os.path.join(current_dir, ".env") -if not os.path.exists(env_path): - env_path = os.path.join(os.path.dirname(current_dir), ".env") - -load_dotenv(env_path) - -# Mattermost 설정 -MM_SERVER_URL = "https://mattermost.hoonfam.org" -MM_BOT_TOKEN = os.environ.get("MM_BOT_TOKEN_", "").strip() -MM_CONFIG_FILE = os.path.join(current_dir, "mm_config.json") - -# [Logger 설정] -logging.basicConfig( - format='[%(asctime)s] %(message)s', - datefmt='%H:%M:%S', - level=logging.INFO -) -logger = logging.getLogger("TradingBotV2") - -# 외부 라이브러리 로그 억제 -logging.getLogger("urllib3").setLevel(logging.WARNING) -logging.getLogger("requests").setLevel(logging.WARNING) -logging.getLogger("httpx").setLevel(logging.WARNING) - -# 키움 API 모듈 임포트 -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 -except ImportError as e: - logger.critical(f"❌ 키움 REST API 모듈 임포트 실패: {e}") - raise e - - -# ========================================================== -# [Part 0] 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", {}) - return {} - 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"❌ '{channel_alias}' 채널 ID 없음") - return False - - payload = {"channel_id": channel_id, "message": message} - try: - res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3) - res.raise_for_status() - return True - except Exception as e: - logger.error(f"❌ MM 전송 에러: {e}") - return False - - -# ========================================================== -# [Part 1] 브로커 API (키움증권 REST API 연동) -# ========================================================== -class BrokerAPI: - def __init__(self): - logger.info("🔵 키움(REST) 브로커 연결 시도...") - try: - self.token_manager = TokenManager() - self.stock_info = StockInfo(token_manager=self.token_manager) - self.chart = Chart(token_manager=self.token_manager) - self.order = Order(token_manager=self.token_manager) - self.rank = RankInfo(token_manager=self.token_manager) - self.account = Account(token_manager=self.token_manager) - self.market = MarketCondition(token_manager=self.token_manager) - self.acc_no = os.environ.get("KIWOOM_ACCOUNT_NO", "") - logger.info(f"✅ 브로커 연결 완료 (계좌: {self.acc_no})") - except Exception as e: - logger.critical(f"❌ 브로커 초기화 실패: {e}") - raise e - - def _safe_request(self, func, *args, **kwargs): - """API 호출 안전장치 (429 에러 핸들링)""" - full_name = func.__name__ - api_id = full_name.split('_')[-1] - max_retries = 3 - - for i in range(max_retries): - try: - time.sleep(1) # 기본 안전 대기 - result = func(*args, **kwargs) - - if isinstance(result, dict) and result.get('return_code') != '0' and '초과' in result.get('msg1', ''): - raise Exception("429 Rate Limit Detected") - - return result - - except Exception as e: - if "429" in str(e) or "과부하" in str(e): - logger.warning(f"⚠️ [{api_id}] API 과부하 -> 5초 대기 ({i + 1}/{max_retries})") - logger.info("5초 대기 시작...") - time.sleep(5) - logger.info("5초 대기 완료, 재시도합니다.") - else: - logger.error(f"❌ [{api_id}] 호출 에러: {e}") - time.sleep(1) - - logger.error(f"💀 [{api_id}] 3회 재시도 실패") - return {} - - def get_deposit_only(self): - """예수금 조회""" - try: - res = self._safe_request(self.account.deposit_detail_status_request_kt00001, qry_tp="2") - d2_deposit = float(res.get('d2_entra', 0)) if res else 0 - current_deposit = float(res.get('ord_alow_amt', 0)) if res else 0 - return 0 if d2_deposit < 0 else current_deposit - except Exception as e: - logger.error(f"예수금 조회 실패: {e}") - return 0 - - def get_account_info(self): - """계좌 평가 정보 조회 (전체 자산, 주식 평가액 등)""" - try: - res = self._safe_request(self.account.account_evaluation_balance_detail_request_kt00018, - qry_tp="2", prd_tp="1") - if not res: - return {'total_asset': 0, 'deposit': 0, 'stock_value': 0, 'profit_rate': 0} - - total_asset = float(res.get('estm_ast_amt', 0)) - deposit = float(res.get('ord_alow_amt', 0)) - stock_value = float(res.get('stck_pbls_amt', 0)) - - # 평가손익률 계산 - deposit_clean = float(res.get('d2_entra', deposit)) - profit_rate = 0.0 - if (deposit_clean + stock_value) > 0: - profit_rate = ((total_asset - (deposit_clean + stock_value)) / (deposit_clean + stock_value)) * 100 - - return { - 'total_asset': total_asset, - 'deposit': deposit, - 'stock_value': stock_value, - 'profit_rate': profit_rate - } - except Exception as e: - logger.error(f"계좌 정보 조회 실패: {e}") - return {'total_asset': 0, 'deposit': 0, 'stock_value': 0, 'profit_rate': 0} - - def check_market_status(self): - """장 운영 시간 체크 (08:30 ~ 16:00)""" - 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 - - def get_ohlcv(self, code, timeframe='3m', limit=100): - """분봉 차트 데이터 조회""" - tic_scope = {"1m": "1", "3m": "3", "5m": "5", "10m": "10"}.get(timeframe, "3") - - try: - res = self._safe_request( - self.chart.stock_minute_chart_request_ka10080, - stk_cd=code, tic_scope=tic_scope, upd_stkpc_tp="1" - ) - data = res.get('stk_min_pole_chart_qry', []) if res else [] - if not data: - return pd.DataFrame() - - df = pd.DataFrame(data) - df = df.rename(columns={ - 'cur_prc': 'close', 'open_pric': 'open', - 'high_pric': 'high', 'low_pric': 'low', - 'trde_qty': 'volume' - }) - - # 시간순 정렬 (과거->현재) - df = df[['open', 'high', 'low', 'close', 'volume']].astype(float).abs() - return df.iloc[::-1].reset_index(drop=True).tail(limit) - - except Exception as e: - logger.error(f"차트 조회 실패({code}): {e}") - return pd.DataFrame() - - def get_current_price(self, code): - """현재가 조회""" - try: - res = self._safe_request( - self.stock_info.watchlist_stock_information_request_ka10095, - stock_code=code - ) - if res and 'atn_stk_infr' in res and len(res['atn_stk_infr']) > 0: - item = res['atn_stk_infr'][0] - return abs(float(item.get('cur_prc', 0))) - return None - except Exception as e: - logger.error(f"현재가 조회 에러({code}): {e}") - return None - - def buy_market_order(self, code, qty): - """시장가 매수 주문""" - try: - res = self._safe_request( - self.order.stock_buy_order_request_kt10000, - dmst_stex_tp="KRX", stk_cd=code, - ord_qty=str(qty), trde_tp="3", ord_uv="0" - ) - if str(res.get('return_code')) == '0': - return True - logger.error(f"매수 주문 실패({code}): {res}") - return False - except Exception as e: - logger.error(f"매수 주문 예외({code}): {e}") - return False - - def sell_market_order(self, code, qty): - """시장가 매도 주문""" - try: - res = self._safe_request( - self.order.stock_sell_order_request_kt10001, - dmst_stex_tp="KRX", stk_cd=code, - ord_qty=str(qty), trde_tp="3", ord_uv="0" - ) - if str(res.get('return_code')) == '0': - return True - logger.error(f"매도 주문 실패({code}): {res}") - return False - except Exception as e: - logger.error(f"매도 주문 예외({code}): {e}") - return False - - -# ========================================================== -# [Part 2] 메인 트레이딩 봇 Ver2 -# ========================================================== -class TradingBotV2: - def __init__(self, broker_api): - self.api = broker_api - - # Mattermost 초기화 - self.mm = MattermostBot() - self.mm_channel = "stock" - - # 파일 경로 - self.bot_state_file = os.path.join(current_dir, 'bot_state.json') - - # DB 초기화 - self.db = TradeDB(db_path="quant_bot.db") - - # Risk Manager 초기화 - kelly_enabled = os.environ.get("USE_KELLY", "false").lower() == "true" - self.risk_mgr = RiskManager( - risk_pct_per_trade=float(os.environ.get("RISK_PCT_PER_TRADE", "0.02")), - max_position_pct=float(os.environ.get("MAX_POSITION_PCT", "0.20")), - min_position_amount=int(os.environ.get("MIN_POSITION_AMOUNT", "50000")), - use_kelly=kelly_enabled - ) - - # Smart Executor 초기화 (TWAP 분할 매수) - use_twap = os.environ.get("USE_TWAP", "false").lower() == "true" - self.use_twap = use_twap - if use_twap: - self.executor = SmartOrderExecutor( - min_split_amount=int(os.environ.get("TWAP_MIN_SPLIT", "500000")), - max_split_amount=int(os.environ.get("TWAP_MAX_SPLIT", "2000000")), - min_delay_seconds=int(os.environ.get("TWAP_MIN_DELAY", "30")), - max_delay_seconds=int(os.environ.get("TWAP_MAX_DELAY", "180")) - ) - else: - self.executor = None - - # 거래 설정 - self.max_stocks = int(os.environ.get("MAX_STOCKS", "5")) - self.stop_loss_pct = float(os.environ.get("STOP_LOSS_PCT", "-0.035")) - self.daily_stop_loss_pct = float(os.environ.get("DAILY_STOP_LOSS_PCT", "-0.05")) - - # 전략 파라미터 - self.rsi_threshold = float(os.environ.get("RSI_OVERHEAT_THRESHOLD", "73")) - self.shoulder_cut_pct = float(os.environ.get("SHOULDER_CUT_PCT", "0.03")) - self.min_recovery_ratio = float(os.environ.get("MIN_RECOVERY_RATIO", "0.5")) - self.max_recovery_ratio = float(os.environ.get("MAX_RECOVERY_RATIO", "0.8")) - - # 상태 변수 - self.current_cash = 0 - self.current_total_asset = 0 - self.start_asset = 0 - self.prev_session_asset = 0 # 이전 실행 시 자산 - self.start_day_asset = 0 # 오늘 장 시작 시 자산 - self.today_date = datetime.datetime.now().strftime("%Y%m%d") - self.trading_halted = False - - # 리포트 플래그 - self.morning_report_sent = False # 오전 장 뜸할 때 (13:00) - self.closing_report_sent = False # 장마감 전 (15:15) - self.final_report_sent = False # 장마감 후 (15:35) - - # 봇 상태 로드 (이전 실행 정보) - self._load_bot_state() - - # 초기 계좌 정보 로드 - self.refresh_account() - - # JSON 마이그레이션 (최초 1회) - self._migrate_from_json_if_needed() - - # 시작 메시지 - self._send_startup_message(kelly_enabled, use_twap) - - # 봇 상태 저장 - self._save_bot_state() - - def _load_bot_state(self): - """이전 실행 시 봇 상태 로드""" - try: - bot_state_file = getattr(self, 'bot_state_file', None) or os.path.join(current_dir, 'bot_state.json') - if os.path.exists(bot_state_file): - with open(self.bot_state_file, 'r', encoding='utf-8') as f: - state = json.load(f) - - self.prev_session_asset = float(state.get('start_equity', 0)) - prev_day = state.get('start_day', '') - - # 날짜가 바뀌었으면 오늘 시작 자산 갱신 필요 - if prev_day != self.today_date: - self.start_day_asset = 0 # 계좌 조회 후 설정 - logger.info(f"📅 날짜 변경 감지: {prev_day} → {self.today_date}") - else: - self.start_day_asset = self.prev_session_asset - - logger.info(f"📂 봇 상태 로드: 이전 자산 {self.prev_session_asset:,.0f}원") - else: - logger.info("📂 봇 상태 파일 없음 (최초 실행)") - self.prev_session_asset = 0 - self.start_day_asset = 0 - except Exception as e: - logger.error(f"❌ 봇 상태 로드 실패: {e}") - self.prev_session_asset = 0 - self.start_day_asset = 0 - - def _save_bot_state(self): - """현재 봇 상태 저장""" - try: - state = { - 'start_equity': self.current_total_asset, - 'start_day': self.today_date, - 'last_update': datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') - } - - with open(self.bot_state_file, 'w', encoding='utf-8') as f: - json.dump(state, f, indent=4, ensure_ascii=False) - - except Exception as e: - logger.error(f"❌ 봇 상태 저장 실패: {e}") - - def _send_startup_message(self, kelly_enabled, use_twap): - """시작 메시지 전송 (이전 실행 대비 손익률 포함)""" - - # 이전 실행 대비 손익률 계산 - if self.prev_session_asset > 0: - session_pnl = self.current_total_asset - self.prev_session_asset - session_pnl_pct = (session_pnl / self.prev_session_asset) * 100 - session_info = f"\n- 이전 실행 대비: {session_pnl:+,.0f}원 ({session_pnl_pct:+.2f}%)" - else: - session_info = "\n- 이전 실행 대비: 데이터 없음 (최초 실행)" - - # 오늘 장 시작 대비 손익률 - if self.start_day_asset > 0 and self.start_day_asset != self.current_total_asset: - day_pnl = self.current_total_asset - self.start_day_asset - day_pnl_pct = (day_pnl / self.start_day_asset) * 100 - day_info = f"\n- 오늘 장 시작 대비: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)" - else: - day_info = "" - - msg = ( - f"🤖 **[트레이딩봇 Ver2 가동]**\n" - f"- 현재 자산: {self.current_total_asset:,.0f}원" - f"{session_info}" - f"{day_info}\n" - f"- DB 기반 안전 관리\n" - f"- 변동성 기반 자금 관리\n" - f"- TWAP: {'ON' if use_twap else 'OFF'}\n" - f"- 켈리 공식: {'ON' if kelly_enabled else 'OFF'}" - ) - - logger.info(msg) - self.send_mm(msg) - - def _migrate_from_json_if_needed(self): - """기존 JSON 파일에서 DB로 마이그레이션 (1회성)""" - portfolio_file = os.path.join(current_dir, 'portfolio.json') - if os.path.exists(portfolio_file): - try: - with open(portfolio_file, 'r', encoding='utf-8') as f: - data = json.load(f) - if data: - count = self.db.migrate_from_json(data) - logger.info(f"📦 JSON 마이그레이션 완료: {count}개 종목") - # 백업 후 삭제 - backup_path = portfolio_file + ".backup" - os.rename(portfolio_file, backup_path) - logger.info(f"💾 기존 JSON 백업: {backup_path}") - except Exception as e: - logger.error(f"❌ JSON 마이그레이션 실패: {e}") - - def send_mm(self, msg): - """Mattermost 알림 전송""" - try: - self.mm.send(self.mm_channel, msg) - except Exception as e: - logger.error(f"❌ MM 전송 에러: {e}") - - def refresh_account(self): - """계좌 정보 갱신""" - try: - info = self.api.get_account_info() - self.current_cash = info['deposit'] - self.current_total_asset = info['total_asset'] - - # 첫 실행 시 시작 자산 설정 - if self.start_asset == 0: - self.start_asset = info['total_asset'] - - # 오늘 장 시작 자산 설정 (날짜 바뀜 or 최초 실행) - if self.start_day_asset == 0: - self.start_day_asset = info['total_asset'] - logger.info(f"📅 오늘 장 시작 자산 설정: {self.start_day_asset:,.0f}원") - - # 손익률 계산 - day_pnl = self.current_total_asset - self.start_day_asset - day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 - - logger.info( - f"💰 예수금: {self.current_cash:,.0f}원 | " - f"총자산: {self.current_total_asset:,.0f}원 " - f"(예수금 {self.current_cash:,.0f} + 주식 {info['stock_value']:,.0f}) | " - f"오늘: {day_pnl:+,.0f}원({day_pnl_pct:+.2f}%)" - ) - - except Exception as e: - logger.error(f"❌ 계좌 정보 갱신 실패: {e}") - - def calculate_rsi(self, df, period=14): - """RSI 계산""" - try: - if df is None or len(df) < period + 1: - return 50 - - delta = df['close'].diff() - gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() - loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() - - rs = gain / loss - rsi = 100 - (100 / (1 + rs)) - return rsi.iloc[-1] if not np.isnan(rsi.iloc[-1]) else 50 - except: - return 50 - - def calculate_atr(self, df, period=14): - """ATR 계산""" - try: - if df is None or len(df) < period: - return 0 - - df = df.copy() - df['tr'] = np.maximum( - df['high'] - df['low'], - np.maximum( - np.abs(df['high'] - df['close'].shift()), - np.abs(df['low'] - df['close'].shift()) - ) - ) - atr = df['tr'].rolling(window=period).mean().iloc[-1] - return atr if not np.isnan(atr) else 0 - except: - return 0 - - def check_buy_signal_tail_catch(self, code, name): - """ - TAIL_CATCH_3M 전략: 3분봉 꼬리 잡기 - - 기존 로직 유지 + 변동성 기반 비중 계산 추가 - """ - try: - df = self.api.get_ohlcv(code, timeframe='3m', limit=50) - if df is None or len(df) < 20: - return None - - current_price = df['close'].iloc[-1] - candle = df.iloc[-1] - - # 1. RSI 과열 필터 - rsi = self.calculate_rsi(df) - if rsi > self.rsi_threshold: - return None - - # 2. 일일 고점 추격 방지 - daily_high = df['high'].max() - if current_price > daily_high * 0.96: - return None - - # 3. 아래꼬리 확인 - candle_low = candle['low'] - candle_high = candle['high'] - candle_open = candle['open'] - candle_close = candle['close'] - - body_top = max(candle_open, candle_close) - body_bottom = min(candle_open, candle_close) - - tail_length = body_bottom - candle_low - body_length = body_top - body_bottom - - if tail_length <= 0 or body_length <= 0: - return None - - # 꼬리 길이 비율 확인 - tail_ratio = tail_length / body_length - tail_pct = tail_length / candle_low - - if tail_ratio < 1.5 or tail_pct < 0.003: - return None - - # 4. 회복 속도 확인 - total_range = candle_high - candle_low - if total_range <= 0: - return None - - recovery_ratio = (current_price - candle_low) / total_range - - if not (self.min_recovery_ratio <= recovery_ratio <= self.max_recovery_ratio): - return None - - # 5. 변동성 계산 및 매수 금액 산출 - atr = self.calculate_atr(df) - - # 켈리 비율 가져오기 - kelly_fraction = None - if self.risk_mgr.use_kelly: - kelly_fraction = self.db.calculate_half_kelly() - - # Risk Manager를 통한 안전 매수 금액 계산 - safe_amount = self.risk_mgr.get_position_size( - stock_name=name, - current_balance=self.current_cash, - df=df, - kelly_fraction=kelly_fraction - ) - - if safe_amount < self.risk_mgr.min_amount: - logger.info(f"🚫 [{name}] 계산된 금액이 최소 매수액 미달") - return None - - # 6. 수량 계산 - qty = self.risk_mgr.calculate_quantity(current_price, safe_amount) - - if qty < 1: - return None - - # 손절가/목표가 설정 (ATR 기반) - atr_multiplier_stop = 3.5 - atr_multiplier_target = 8.0 - - stop_price = current_price - (atr * atr_multiplier_stop) - target_price = current_price + (atr * atr_multiplier_target) - - logger.info( - f"✅ [{name}] TAIL_CATCH 시그널 발생 | " - f"가격:{current_price:,.0f} | RSI:{rsi:.1f} | " - f"꼬리비율:{tail_ratio:.2f} | 회복:{recovery_ratio:.2%} | " - f"매수:{safe_amount:,.0f}원({qty}주)" - ) - - return { - 'code': code, - 'name': name, - 'price': current_price, - 'qty': qty, - 'amount': safe_amount, - 'strategy': 'TAIL_CATCH_3M', - 'stop_price': stop_price, - 'target_price': target_price, - 'atr': atr - } - - except Exception as e: - logger.error(f"❌ [{name}] 매수 시그널 체크 실패: {e}") - return None - - def execute_buy(self, signal): - """매수 실행 (TWAP 분할 매수 또는 즉시 매수)""" - try: - code = signal['code'] - name = signal['name'] - qty = signal['qty'] - amount = signal['amount'] - - # DB에 이미 있는지 확인 - active_trades = self.db.get_active_trades() - if code in active_trades: - logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵") - return False - - # TWAP 활성화 시 스마트 주문 등록 - if self.use_twap and self.executor and amount >= 1000000: # 100만원 이상만 분할 - self.executor.add_order(code, name, amount, duration_minutes=30) - logger.info(f"📝 [{name}] TWAP 분할 매수 등록: {amount:,.0f}원") - return True - - # 일반 매수 (즉시 실행) - success = self.api.buy_market_order(code, qty) - - if success: - # DB에 저장 - trade_data = { - 'code': code, - 'name': name, - 'avg_buy_price': signal['price'], - 'stop_price': signal['stop_price'], - 'target_price': signal['target_price'], - 'atr_entry': signal['atr'], - 'target_qty': qty, - 'current_qty': qty, - 'total_invested': signal['price'] * qty, - 'status': 'HOLDING', - 'strategy': signal['strategy'] - } - - self.db.upsert_trade(trade_data) - - msg = f"💰 **[매수 체결]** {name}\n가격: {signal['price']:,.0f}원 × {qty}주" - logger.info(msg) - self.send_mm(msg) - - return True - - return False - - except Exception as e: - logger.error(f"❌ 매수 실행 실패: {e}") - return False - - def check_sell_signals(self): - """보유 종목 매도 시그널 체크""" - active_trades = self.db.get_active_trades() - - if not active_trades: - return - - for code, trade in list(active_trades.items()): - try: - name = trade['name'] - buy_price = trade['avg_buy_price'] - stop_price = trade['stop_price'] - target_price = trade['target_price'] - qty = trade['current_qty'] - - # 현재가 조회 - current_price = self.api.get_current_price(code) - if not current_price: - continue - - # DB 현재가 업데이트 - self.db.update_current_price(code, current_price) - - # 손익률 계산 - profit_rate = ((current_price - buy_price) / buy_price) * 100 - - # 매도 사유 판단 - sell_reason = None - - # 1. 손절 - if current_price <= stop_price: - sell_reason = f"손절({profit_rate:.1f}%)" - - # 2. 목표가 달성 - elif current_price >= target_price: - sell_reason = f"목표달성({profit_rate:.1f}%)" - - # 3. 어깨 매도 (고점 대비 하락) - max_price = trade.get('max_price', buy_price) - if current_price > max_price: - self.db.update_max_price(code, current_price) - else: - drop_from_high = (max_price - current_price) / max_price - if drop_from_high > self.shoulder_cut_pct: - sell_reason = f"어깨매도({profit_rate:.1f}%)" - - # 매도 실행 - if sell_reason: - if self.api.sell_market_order(code, qty): - self.db.close_trade(code, current_price, sell_reason) - - msg = f"📤 **[매도 체결]** {name}\n사유: {sell_reason}\n수익: {profit_rate:+.2f}%" - logger.info(msg) - self.send_mm(msg) - - except Exception as e: - logger.error(f"❌ [{code}] 매도 체크 실패: {e}") - - def send_morning_report(self): - """오전 장 뜸할 때 리포트 (13:00)""" - try: - # 계좌 정보 갱신 - info = self.api.get_account_info() - - # 손익 계산 - day_pnl = info['total_asset'] - self.start_day_asset - day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 - - # 보유 종목 정보 - active_trades = self.db.get_active_trades() - holdings_info = "" - - if active_trades: - holdings_info = "\n\n**보유 종목:**" - for code, trade in active_trades.items(): - current_price = self.api.get_current_price(code) - if current_price: - profit = ((current_price - trade['avg_buy_price']) / trade['avg_buy_price']) * 100 - holdings_info += f"\n- {trade['name']}: {profit:+.2f}%" - - # 오늘 거래 통계 - today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') - cursor = self.db.conn.execute( - "SELECT COUNT(*) as cnt, SUM(realized_pnl) as pnl FROM trade_history WHERE sell_date >= ?", - (today_start,) - ) - row = cursor.fetchone() - today_trades = row['cnt'] if row else 0 - today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 - - msg = ( - f"🌞 **[오전 장 리포트 13:00]**\n" - f"- 시작 자산: {self.start_day_asset:,.0f}원\n" - f"- 현재 자산: {info['total_asset']:,.0f}원\n" - f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" - f"- 오늘 거래: {today_trades}건 ({today_trade_pnl:+,.0f}원)" - f"{holdings_info}" - ) - - logger.info(msg) - self.send_mm(msg) - self.morning_report_sent = True - - except Exception as e: - logger.error(f"❌ 오전 리포트 전송 실패: {e}") - - def send_closing_report(self): - """장마감 전 리포트 (15:15)""" - try: - info = self.api.get_account_info() - - day_pnl = info['total_asset'] - self.start_day_asset - day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 - - # 오늘 거래 통계 - today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') - cursor = self.db.conn.execute( - "SELECT COUNT(*) as cnt, SUM(realized_pnl) as pnl FROM trade_history WHERE sell_date >= ?", - (today_start,) - ) - row = cursor.fetchone() - today_trades = row['cnt'] if row else 0 - today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 - - msg = ( - f"🔔 **[장마감 전 리포트 15:15]**\n" - f"- 현재 자산: {info['total_asset']:,.0f}원\n" - f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" - f"- 오늘 거래: {today_trades}건 ({today_trade_pnl:+,.0f}원)\n" - f"- 보유 종목: {len(self.db.get_active_trades())}개" - ) - - logger.info(msg) - self.send_mm(msg) - self.closing_report_sent = True - - except Exception as e: - logger.error(f"❌ 장마감 전 리포트 전송 실패: {e}") - - def send_final_report(self): - """장마감 후 최종 리포트 (15:35)""" - try: - info = self.api.get_account_info() - - # 오늘 손익 - day_pnl = info['total_asset'] - self.start_day_asset - day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 - - # 누적 손익 (총 입금액 대비) - # 총 입금액은 환경 변수나 별도 파일로 관리 필요 - total_deposit = float(os.environ.get("TOTAL_DEPOSIT", str(self.start_day_asset))) - total_pnl = info['total_asset'] - total_deposit - total_pnl_pct = (total_pnl / total_deposit * 100) if total_deposit > 0 else 0 - - # 오늘 거래 통계 - today_start = datetime.datetime.now().strftime('%Y-%m-%d 00:00:00') - cursor = self.db.conn.execute( - """SELECT - COUNT(*) as cnt, - SUM(CASE WHEN profit_rate > 0 THEN 1 ELSE 0 END) as wins, - SUM(realized_pnl) as pnl - FROM trade_history - WHERE sell_date >= ?""", - (today_start,) - ) - row = cursor.fetchone() - today_trades = row['cnt'] if row else 0 - today_wins = row['wins'] if row else 0 - today_trade_pnl = row['pnl'] if row and row['pnl'] else 0 - today_win_rate = (today_wins / today_trades * 100) if today_trades > 0 else 0 - - # 전체 통계 - stats = self.db.get_trade_stats() - - msg = ( - f"🌙 **[장마감 최종 리포트 15:35]**\n\n" - f"**📊 오늘 실적**\n" - f"- 시작: {self.start_day_asset:,.0f}원 → 종료: {info['total_asset']:,.0f}원\n" - f"- 오늘 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%)\n" - f"- 오늘 거래: {today_trades}건 (익절 {today_wins}건, 승률 {today_win_rate:.1f}%)\n" - f"- 거래 손익: {today_trade_pnl:+,.0f}원\n\n" - f"**💰 누적 실적**\n" - f"- 총 입금액: {total_deposit:,.0f}원\n" - f"- 현재 자산: {info['total_asset']:,.0f}원\n" - f"- 누적 손익: {total_pnl:+,.0f}원 ({total_pnl_pct:+.2f}%)\n" - f"- 전체 거래: {stats['total_trades']}건 (승률 {stats['win_rate']:.1f}%)\n" - f"- 전체 손익: {stats['total_pnl']:+,.0f}원" - ) - - logger.info(msg) - self.send_mm(msg) - self.final_report_sent = True - - # 다음날을 위해 상태 저장 - self._save_bot_state() - - except Exception as e: - logger.error(f"❌ 최종 리포트 전송 실패: {e}") - - def process_twap_orders(self): - """TWAP 분할 매수 처리""" - if not self.use_twap or not self.executor: - return - - # 현재가 정보 수집 - active_orders = self.executor.get_status() - if not active_orders: - return - - current_prices = {} - for code in active_orders.keys(): - price = self.api.get_current_price(code) - if price: - current_prices[code] = price - - # 매수 콜백 함수 - def buy_callback(code, name, amount, price): - qty = int(amount / price) - if qty < 1: - return False - - success = self.api.buy_market_order(code, qty) - - if success: - # DB 업데이트 (분할 매수 누적) - active_trades = self.db.get_active_trades() - - if code in active_trades: - # 기존 보유분 있음 -> 평단가 계산 - existing = active_trades[code] - old_qty = existing['current_qty'] - old_price = existing['avg_buy_price'] - old_invested = existing['total_invested'] - - new_qty = old_qty + qty - new_invested = old_invested + (price * qty) - new_avg_price = new_invested / new_qty - - self.db.upsert_trade({ - 'code': code, - 'name': name, - 'avg_buy_price': new_avg_price, - 'current_qty': new_qty, - 'total_invested': new_invested, - 'status': 'HOLDING', - **existing # 기존 정보 유지 - }) - else: - # 신규 매수 - self.db.upsert_trade({ - 'code': code, - 'name': name, - 'avg_buy_price': price, - 'target_qty': qty, # 임시 - 'current_qty': qty, - 'total_invested': price * qty, - 'status': 'HOLDING', - 'strategy': 'TAIL_CATCH_3M' - }) - - return True - - return False - - # TWAP 처리 - self.executor.process_orders(current_prices, buy_callback) - - def run(self): - """메인 루프""" - logger.info("🚀 트레이딩봇 Ver2 가동 시작") - - loop_count = 0 - - while True: - try: - loop_count += 1 - now = datetime.datetime.now() - current_time = now.time() - - # 1. 장 운영 시간 체크 - if not self.api.check_market_status(): - # 장 시간 외: 플래그 초기화 - if now.weekday() < 5: # 주중 - # 날짜가 바뀌었는지 확인 - today_str = now.strftime("%Y%m%d") - if today_str != self.today_date: - logger.info(f"📅 날짜 변경: {self.today_date} → {today_str}") - self.today_date = today_str - self.start_day_asset = 0 - self.morning_report_sent = False - self.closing_report_sent = False - self.final_report_sent = False - - if loop_count % 60 == 0: # 1분마다 - logger.info("💤 장 운영 시간 외") - time.sleep(60) - continue - - # 2. 장 중 리포트 타이밍 체크 - # 오전 장 뜸할 때: 13:00 - if not self.morning_report_sent and datetime.time(13, 0) <= current_time < datetime.time(13, 5): - self.send_morning_report() - - # 장마감 전: 15:15 - if not self.closing_report_sent and datetime.time(15, 15) <= current_time < datetime.time(15, 20): - self.send_closing_report() - - # 장마감 후: 15:35 (모든 체결 완료 후) - if not self.final_report_sent and datetime.time(15, 35) <= current_time < datetime.time(15, 40): - self.send_final_report() - - # 3. 계좌 정보 갱신 (5분마다) - if loop_count % 30 == 0: - self.refresh_account() - - # 4. TWAP 분할 매수 처리 - if self.use_twap: - self.process_twap_orders() - - # 5. 보유 종목 매도 체크 - self.check_sell_signals() - - # 6. 새로운 매수 기회 탐색 (보유 종목이 max보다 적을 때) - active_count = len(self.db.get_active_trades()) - - if active_count < self.max_stocks and self.current_cash >= self.risk_mgr.min_amount: - # 실제로는 scan_ant_shaking_candidates 같은 로직 필요 - # 여기서는 예시로 생략 - pass - - # 7. 대기 - time.sleep(10) - - except KeyboardInterrupt: - logger.info("⏸️ 사용자 중단") - break - except Exception as e: - logger.error(f"❌ 메인 루프 에러: {e}") - logger.info("⏳ 예외 발생 -> 5초 대기 후 재시도") - time.sleep(5) - - # 종료 처리 - logger.info("🛑 봇 종료 중...") - self._save_bot_state() # 최종 상태 저장 - if self.db: - self.db.close() - logger.info("✅ 정상 종료 완료") - - -# ========================================================== -# [메인 실행] -# ========================================================== -if __name__ == "__main__": - try: - broker = BrokerAPI() - bot = TradingBotV2(broker) - bot.run() - except Exception as e: - logger.critical(f"💀 봇 실행 실패: {e}") - raise e +""" +Kiwoom Trading Bot Ver2 - DB 기반 고급 트레이딩 시스템 +- SQLite DB 기반 안전한 데이터 관리 +- 변동성 기반 자금 관리 (Risk Manager) +- TWAP 스마트 분할 매수 (Smart Executor) +- 하프 켈리 공식 적용 +- 기존 TAIL_CATCH_3M 전략 유지 및 강화 +""" + +import time +import json +import datetime +import asyncio +import pandas as pd +import numpy as np +import os +import logging +import requests +import random +from dotenv import load_dotenv + +# 새로운 모듈 임포트 +from database import TradeDB +from risk_manager import RiskManager +from smart_executor import SmartOrderExecutor + +from ml_predictor import MLPredictor +from news_analyzer import NewsAnalyzer +from trend_divergence import TrendDivergenceAnalyzer +from export_sniper import ExportSniper +# ========================================================== +# [Step 0] 환경 변수 및 기본 설정 +# ========================================================== +current_dir = os.path.dirname(os.path.abspath(__file__)) +env_path = os.path.join(current_dir, ".env") +if not os.path.exists(env_path): + env_path = os.path.join(os.path.dirname(current_dir), ".env") + +load_dotenv(env_path) + +# Mattermost 설정 +MM_SERVER_URL = "https://mattermost.hoonfam.org" +MM_BOT_TOKEN = os.environ.get("MM_BOT_TOKEN_", "").strip() +MM_CONFIG_FILE = os.path.join(current_dir, "mm_config.json") + +# [Logger 설정] +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO +) +logger = logging.getLogger("TradingBotV2") + +# 외부 라이브러리 로그 억제 +logging.getLogger("urllib3").setLevel(logging.WARNING) +logging.getLogger("requests").setLevel(logging.WARNING) +logging.getLogger("httpx").setLevel(logging.WARNING) + +# ========================================================== +# [Helper 함수] 환경변수 안전 로드 +# ========================================================== +def get_env_float(key, default): + """환경변수를 float로 변환 (주석 제거)""" + value = os.environ.get(key, default) + # 주석 제거 (# 이후 문자열 제거) + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + return float(value) + +def get_env_int(key, default): + """환경변수를 int로 변환 (주석 제거)""" + value = os.environ.get(key, default) + # 주석 제거 + if isinstance(value, str) and "#" in value: + value = value.split("#")[0].strip() + return int(value) + + +# 키움 API 모듈 임포트 +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 + + +# ========================================================== +# [Part 0] 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", {}) + return {} + 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"❌ '{channel_alias}' 채널 ID 없음") + return False + + payload = {"channel_id": channel_id, "message": message} + try: + res = requests.post(self.api_url, headers=self.headers, json=payload, timeout=3) + res.raise_for_status() + return True + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + return False + + +# ========================================================== +# [Part 1] 브로커 API (키움증권 REST API 연동) +# ========================================================== +class BrokerAPI: + def __init__(self): + logger.info("🔵 키움(REST) 브로커 연결 시도...") + try: + self.token_manager = TokenManager() + self.stock_info = StockInfo(token_manager=self.token_manager) + self.chart = Chart(token_manager=self.token_manager) + self.order = Order(token_manager=self.token_manager) + self.rank = RankInfo(token_manager=self.token_manager) + self.account = Account(token_manager=self.token_manager) + self.market = MarketCondition(token_manager=self.token_manager) + # 퀀트 트레이딩 필수 API 추가 + 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"✅ 브로커 연결 완료 (계좌: {self.acc_no})") + except Exception as e: + logger.critical(f"❌ 브로커 초기화 실패: {e}") + raise e + + def _safe_request(self, func, *args, **kwargs): + """API 호출 안전장치 (429 / 과부하 핸들링) + + - 키움 REST API의 호출 초과(429, '초과', '과부하')를 감지 + - 지수 백오프(1s → 2s → 4s)에 약간의 지터(jitter)를 섞어 재시도 + - 기존 로직과 동일하게, 최대 재시도 후에는 {} 반환 + """ + full_name = func.__name__ + api_id = full_name.split('_')[-1] + max_retries = 3 + logger.debug(f"💀 [{api_id}] _safe_request 호출") + + for i in range(max_retries): + try: + # 기본 안전 대기 (서버 부하 완화용) + time.sleep(1) + result = func(*args, **kwargs) + + # 키움 REST는 HTTP 200 + return_code/msg1 조합으로 "호출 초과"를 알리는 경우가 있음 + if isinstance(result, dict): + msg1 = str(result.get('msg1', '')) + return_code = str(result.get('return_code', '0')) + # '초과', '과부하' 등의 키워드로 레이트 리밋/과부하 감지 + if return_code != '0' and ('초과' in msg1 or '과부하' in msg1): + raise Exception(f"RateLimitOrOverload: {msg1}") + + return result + + except Exception as e: + msg = str(e) + # 429 / 호출 초과 / 과부하 계열 에러: 지수 백오프 + 지터 + if ("429" in msg) or ("RateLimit" in msg) or ("초과" in msg) or ("과부하" in msg): + # 1, 2, 4초 + 약간의 랜덤 지터 (0.5~1.5초) + base = 2 ** i + wait = base + random.uniform(0.5, 1.5) + logger.warning( + f"⚠️ [{api_id}] API 호출 제한 또는 과부하 감지 -> {wait:.1f}초 대기 후 재시도 " + f"({i + 1}/{max_retries}) | 에러: {msg}" + ) + time.sleep(wait) + else: + # 그 외 에러는 기존처럼 짧게 대기 후 다음 루프 + logger.error(f"❌ [{api_id}] 호출 에러: {e}") + time.sleep(1) + + logger.error(f"💀 [{api_id}] 3회 재시도 실패") + return {} + + def get_deposit_only(self): + """예수금 조회""" + try: + res = self._safe_request(self.account.deposit_detail_status_request_kt00001, qry_tp="2") + d2_deposit = float(res.get('d2_entra', 0)) if res else 0 + current_deposit = float(res.get('ord_alow_amt', 0)) if res else 0 + return 0 if d2_deposit < 0 else current_deposit + except Exception as e: + logger.error(f"예수금 조회 실패: {e}") + return 0 + + def get_account_info(self): + """계좌 평가 정보 조회 (전체 자산, 주식 평가액, 보유 종목 리스트)""" + try: + # 예수금 조회 + deposit = self.get_deposit_only() + + # 잔고 조회 (보유 종목 리스트 포함!) + res = self._safe_request( + self.account.account_evaluation_balance_detail_request_kt00018, + query_type="1", # 1:합산, 2:개별 + domestic_exchange_type="KRX" # 한국거래소 + ) + + if not res: + return { + 'total_asset': deposit, + 'deposit': deposit, + 'stock_value': 0, + 'holdings': {} # 🔥 보유 종목 리스트 추가 + } + + # 주식평가금액 + stock_value = float(res.get('tot_evlt_amt', 0)) + + # 총자산 = 예수금 + 주식평가금액 + total_asset = deposit + stock_value + + # 🔥 보유 종목 리스트 파싱 (Dual 로직 이식!) + holdings = {} + if 'acnt_evlt_remn_indv_tot' in res: + for item in res['acnt_evlt_remn_indv_tot']: + code = item['stk_cd'].strip() + if code.startswith('A'): + code = code[1:] # 'A005930' → '005930' + + qty = int(item.get('rmnd_qty', 0)) + if qty <= 0: + continue # 수량 0인 종목 제외 + + holdings[code] = { + 'name': item['stk_nm'].strip(), + 'qty': qty, + 'buy_price': abs(float(item.get('pur_pric', 0))), + 'current_price': abs(float(item.get('cur_prc', 0))), + 'profit_rate': float(item.get('erng_rt', 0)) + } + + return { + 'total_asset': total_asset, + 'deposit': deposit, + 'stock_value': stock_value, + 'holdings': holdings # 🔥 {code: {name, qty, buy_price, current_price, profit_rate}} + } + except Exception as e: + logger.error(f"계좌 정보 조회 실패: {e}") + import traceback + logger.error(f"상세 오류:\n{traceback.format_exc()}") + return { + 'total_asset': 0, + 'deposit': 0, + 'stock_value': 0, + 'holdings': {} + } + + def check_market_status(self): + """장 운영 시간 체크 (08:30 ~ 16:00)""" + 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 + + def get_ohlcv(self, code, timeframe='3m', limit=100): + """분봉 차트 데이터 조회""" + tic_scope = {"1m": "1", "3m": "3", "5m": "5", "10m": "10"}.get(timeframe, "3") + + try: + res = self._safe_request( + self.chart.stock_minute_chart_request_ka10080, + stk_cd=code, tic_scope=tic_scope, upd_stkpc_tp="1" + ) + data = res.get('stk_min_pole_chart_qry', []) if res else [] + if not data: + return pd.DataFrame() + + df = pd.DataFrame(data) + df = df.rename(columns={ + 'cur_prc': 'close', 'open_pric': 'open', + 'high_pric': 'high', 'low_pric': 'low', + 'trde_qty': 'volume' + }) + + # 시간순 정렬 (과거->현재) + df = df[['open', 'high', 'low', 'close', 'volume']].astype(float).abs() + return df.iloc[::-1].reset_index(drop=True).tail(limit) + + except Exception as e: + logger.error(f"차트 조회 실패({code}): {e}") + return pd.DataFrame() + + def get_current_price(self, code): + """현재가 조회""" + try: + res = self._safe_request( + self.stock_info.watchlist_stock_information_request_ka10095, + stock_code=code + ) + if res and 'atn_stk_infr' in res and len(res['atn_stk_infr']) > 0: + item = res['atn_stk_infr'][0] + return abs(float(item.get('cur_prc', 0))) + return None + except Exception as e: + logger.error(f"현재가 조회 에러({code}): {e}") + return None + + def buy_market_order(self, code, qty): + """시장가 매수 주문""" + try: + res = self._safe_request( + self.order.stock_buy_order_request_kt10000, + dmst_stex_tp="KRX", stk_cd=code, + ord_qty=str(qty), trde_tp="3", ord_uv="0" + ) + if str(res.get('return_code')) == '0': + return True + logger.error(f"매수 주문 실패({code}): {res}") + return False + except Exception as e: + logger.error(f"매수 주문 예외({code}): {e}") + return False + + def sell_market_order(self, code, qty): + """시장가 매도 주문""" + try: + res = self._safe_request( + self.order.stock_sell_order_request_kt10001, + dmst_stex_tp="KRX", stk_cd=code, + ord_qty=str(qty), trde_tp="3", ord_uv="0" + ) + if str(res.get('return_code')) == '0': + return True + logger.error(f"매도 주문 실패({code}): {res}") + return False + except Exception as e: + logger.error(f"매도 주문 예외({code}): {e}") + return False + + # ========================================================== + # [퀀트 분석 메소드] - Sector (업종 분석) + # ========================================================== + + def get_top_sectors(self, market="001", top_n=5): + """상승률 상위 업종 조회""" + try: + res = self._safe_request( + self.sector.all_industries_index_request_ka20003, + mrkt_tp=market # 001:코스피, 101:코스닥 + ) + if not res: + return [] + + # 업종 데이터 정렬 (상승률 기준) + sectors = res.get('all_indx_qry', []) + sorted_sectors = sorted(sectors, + key=lambda x: float(x.get('fluc_rt', 0)), + reverse=True) + return sorted_sectors[:top_n] + except Exception as e: + logger.error(f"업종 조회 실패: {e}") + return [] + + def get_sector_investor_trend(self, market="001"): + """업종별 투자자 순매수 현황""" + try: + res = self._safe_request( + self.sector.industrywise_investor_net_buy_request_ka10051, + mrkt_tp=market, # 001:코스피, 101:코스닥 + stex_tp="1" # 1:KRX + ) + return res.get('indiv_nvst_netby_indu', []) if res else [] + except Exception as e: + logger.error(f"업종 투자자 동향 조회 실패: {e}") + return [] + + # ========================================================== + # [퀀트 분석 메소드] - ForeignInstitution (수급 분석) + # ========================================================== + + 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", # 1:연속순매도, 2:연속순매수 + base_dt_tp="1", # 0:당일기준, 1:전일기준 + stex_tp="1" # 1:KRX + ) + 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, # 시작일자 (YYYYMMDD) + mrkt_tp=market, # 시장구분 + trde_tp="1", # 1:순매수, 2:순매도 + sort_cnd="1", # 1:수량, 2:금액 + unit_tp="1", # 1:단주, 1000:천주 + stex_tp="1" # 1:KRX + ) + stocks = res.get('eql_nettrde_rank', []) if res else [] + # 기관 순매수만 필터 (orgn_nettrde_qty > 0) + 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_foreign_stock_trend(self, stock_code, period="D"): + """특정 종목의 외국인 매매동향""" + try: + res = self._safe_request( + self.foreign_inst.foreign_investor_stockwise_trading_trend_request_ka10008, + stk_cd=stock_code, + period=period, # D:일, W:주, M:월 + stex_tp="1" + ) + return res.get('forgn_nvst_stk_trd_tnd', []) if res else [] + except Exception as e: + logger.error(f"외국인 매매동향 조회 실패({stock_code}): {e}") + return [] + + # ========================================================== + # [퀀트 분석 메소드] - RankInfo (종목 스크리닝) + # ========================================================== + + 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", # 1:급증량, 2:급증률, 3:급감량, 4:급감률 + tm_tp="2", # 1:분, 2:전일 + trde_qty_tp=min_volume, # 5, 10, 50, 100, 200... + stk_cnd="1", # 관리종목제외 + pric_tp="0", # 0:전체조회 + stex_tp="1" # 1:KRX + ) + stocks = res.get('trde_qty_sdnin', []) if res else [] + return stocks[:limit] + except Exception as e: + logger.error(f"거래량 급증 조회 실패: {e}") + return [] + + def get_volume_surge_stocks_full_pages( + self, + market="001", + min_volume="50", + stk_cnd="1", + max_pages=5, + ): + """ + 거래량 급증 종목 - 한 stk_cnd에 대해 연속조회(next_key)로 전체 페이지 수집. + - next_key는 '같은 조건의 다음 페이지'만 가져오므로, 여러 stk_cnd를 쓰려면 + get_volume_surge_stocks_multi_stk_cnd() 사용. + """ + collected = [] + next_key = "" + page = 0 + try: + while page < max_pages: + page += 1 + 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=stk_cnd, + pric_tp="0", + stex_tp="1", + cont_yn="Y" if next_key else "N", + next_key=next_key, + ) + if not res or "trde_qty_sdnin" not in res: + break + chunk = res.get("trde_qty_sdnin", []) + collected.extend(chunk) + next_key = res.get("next-key", "") + if not next_key: + break + time.sleep(random.uniform(1.0, 2.0)) + return collected + except Exception as e: + logger.error(f"거래량 급증 연속조회 실패: {e}") + return collected + + def get_volume_surge_stocks_multi_stk_cnd( + self, + stk_cnd_list=None, + market="001", + min_volume="50", + limit=100, + delay_sec=(1, 3), + ): + """ + 여러 stk_cnd(종목조건)로 각각 API 호출 후 결과를 append. + - next_key로는 다른 stk_cnd를 이어받을 수 없으므로, 조건마다 별도 호출 필요. + - 호출 간 딜레이로 429(횟수 제한) 완화. + - stk_cnd_list 예: ["0", "1", "4"] (전체, 관리제외, 관리+우선주제외) + """ + if stk_cnd_list is None: + stk_cnd_list = ["1"] + out = [] + seen_codes = set() + for stk_cnd in stk_cnd_list: + 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=stk_cnd, + pric_tp="0", + stex_tp="1", + cont_yn="N", + next_key="", + ) + if res and "trde_qty_sdnin" in res: + for item in res["trde_qty_sdnin"]: + code = item.get("stk_cd", "").split("_")[0] + if code and code not in seen_codes: + seen_codes.add(code) + out.append(item) + if delay_sec and stk_cnd != stk_cnd_list[-1]: + time.sleep(random.uniform(delay_sec[0], delay_sec[1])) + except Exception as e: + logger.warning(f"거래량 급증 stk_cnd={stk_cnd} 조회 실패: {e}") + return out[:limit] + + 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, # 1:상승률, 2:상승폭, 3:하락률, 4:하락폭, 5:보합 + trde_qty_cnd="0000", # 거래량조건 + stk_cnd="1", # 1:관리종목제외 + crd_cnd="0", # 0:전체조회 + updown_incls="1", # 0:불포함, 1:포함 + pric_cnd="0", # 0:전체조회 + trde_prica_cnd="0", # 0:전체조회 + stex_tp="1" # 1:KRX + ) + stocks = res.get('pred_pre_flu_rt_upper', []) if res else [] + return stocks[:limit] + except Exception as e: + logger.error(f"등락률 조회 실패: {e}") + return [] + + def get_top_volume_stocks(self, market="001", limit=20): + """당일 거래량 상위 종목""" + try: + res = self._safe_request( + self.rank.top_trading_volume_today_request_ka10030, + mrkt_tp=market, + trde_qty_tp="0000", + stk_cnd="1", + stex_tp="1" + ) + stocks = res.get('td_trde_qty_upper', []) if res else [] + return stocks[:limit] + except Exception as e: + logger.error(f"거래량 상위 조회 실패: {e}") + return [] + + # ========================================================== + # [퀀트 분석 메소드] - Theme (테마 분석) + # ========================================================== + + def get_hot_themes(self, limit=10): + """급등 테마 조회 (Theme API ka90001: 테마그룹별 조회, 상위등락률)""" + try: + res = self._safe_request( + self.theme.theme_group_list_request_ka90001, + qry_tp="0", # 전체검색 + date_tp="1", # 1일 전 기준 + flu_pl_amt_tp="3", # 3: 상위등락률 (급등 테마) + stex_tp="1", + ) + themes = res.get("thema_grp", []) if res else [] + # 등락률(flu_rt) 기준 정렬 후 상위 limit개 + sorted_themes = sorted( + themes, + key=lambda x: float(x.get("flu_rt", 0)), + reverse=True, + ) + return sorted_themes[:limit] + except Exception as e: + logger.error(f"테마 조회 실패: {e}") + return [] + + # ========================================================== + # [퀀트 분석 메소드] - ETF + # ========================================================== + + def get_etf_ranking(self, market="001", limit=20): + """ETF 거래대금 상위""" + try: + res = self._safe_request( + self.etf.etf_ranking_request_ka10133, + mrkt_tp=market, + sort_tp="4", # 4:거래대금 + stex_tp="1" + ) + etfs = res.get('etf_rank', []) if res else [] + return etfs[:limit] + except Exception as e: + logger.error(f"ETF 순위 조회 실패: {e}") + return [] + + # ========================================================== + # [핵심 전략 메소드] - 개미털기 & 장중 수급 + # ========================================================== + + def get_intraday_investor(self, code): + """ + 장중 투자자별 매매 차트 (수급 확인용) + - 외국인/기관의 실시간 순매수 수량을 리턴 + """ + try: + res = self._safe_request( + self.chart.intraday_investor_trading_chart_request_ka10064, + mrkt_tp="000", + amt_qty_tp="2", # 2:수량 + trde_tp="0", # 0:전체 + stk_cd=code + ) + + if not res or 'opmr_invsr_trde_chart' not in res: + return 0, 0 + + data_list = res['opmr_invsr_trde_chart'] + if not data_list: + return 0, 0 + + latest = data_list[0] + foreigner = int(latest.get('frgnr_invsr', 0)) + institution = int(latest.get('orgn', 0)) + + return foreigner, institution + except Exception as e: + logger.debug(f"장중 수급 조회 실패({code}): {e}") + return 0, 0 + + def scan_ant_shaking_candidates(self, max_price_limit=None): + """ + [조건검색 대체 로직] 개미털기(눌림목) 후보 종목 스캔 + - 거래대금 및 회전율 상위 종목 중, 고점 대비 일정 비율 하락 후 반등 시도하는 종목 추출 + + 📊 수집 전략: + 1. 거래대금 상위 (sort_tp=3) + 2. 회전율 상위 (sort_tp=2) + → 합친 후 중복 제거 + + 💎 강도 계산: + - drop_rate = (시가 - 저가) / 시가 (낙폭 %) + - recovery = (현재가 - 저가) / (고가 - 저가) (회복률) + - 조건: 낙폭 3% 이상 & 회복 50% 이상 + - 점수 = drop_rate * 100 (낙폭이 클수록 강함!) + """ + logger.info(f"🐜 [개미털기] 스캔 시작") + 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_price = abs(float(stock.get('open_pric', price))) + change_rate = ((price - open_price) / open_price * 100) if open_price > 0 else 0 + + if price < 1000: # 동전주 제외 + continue + if change_rate > 20: # 상한가 근처 제외 + logger.info(f"🚫 상한가 제외: {stock['stk_nm']} (+{change_rate:.1f}%)") + continue + if price > 200000: # 20만원 이상 제외 + 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 + + if max_price_limit and cl > max_price_limit: + continue + + # 낙폭 계산 + drop_rate = (op - lo) / op + total_range = hi - lo + recovery_pos = (cl - lo) / total_range if total_range > 0 else 0 + + # 낙폭이 3% 이상, 아래꼬리를 달고 올라온 종목 + 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 _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 + + +# ========================================================== +# [Part 2] 메인 트레이딩 봇 Ver2 +# ========================================================== +class TradingBotV2: + def __init__(self, broker_api): + self.api = broker_api + + # Mattermost 초기화 + self.mm = MattermostBot() + self.mm_channel = "stock" + + # DB 초기화 + self.db = TradeDB(db_path="quant_bot.db") + + # Risk Manager 초기화 + kelly_enabled = os.environ.get("USE_KELLY", "false").lower() == "true" + self.risk_mgr = RiskManager( + risk_pct_per_trade=get_env_float("RISK_PCT_PER_TRADE", "0.02"), + max_position_pct=get_env_float("MAX_POSITION_PCT", "0.20"), + min_position_amount=get_env_int("MIN_POSITION_AMOUNT", "50000"), + use_kelly=kelly_enabled + ) + + # Smart Executor 초기화 (TWAP 분할 매수) + use_twap = os.environ.get("USE_TWAP", "false").lower() == "true" + self.use_twap = use_twap + if use_twap: + self.executor = SmartOrderExecutor( + min_split_amount=get_env_int("TWAP_MIN_SPLIT", "500000"), + max_split_amount=get_env_int("TWAP_MAX_SPLIT", "2000000"), + min_delay_seconds=get_env_int("TWAP_MIN_DELAY", "30"), + max_delay_seconds=get_env_int("TWAP_MAX_DELAY", "180") + ) + else: + self.executor = None + + # ML Predictor 초기화 + self.use_ml = os.environ.get("USE_ML_SIGNAL", "false").lower() == "true" + self.ml_min_prob = get_env_float("ML_MIN_PROBABILITY", "0.65") + if self.use_ml: + try: + self.ml_predictor = MLPredictor(db_path="quant_bot.db") + if self.ml_predictor.should_retrain(): + logger.info("🤖 ML 모델 학습 시작...") + self.ml_predictor.train_model(retrain=True) + except Exception as e: + logger.warning(f"⚠️ ML 초기화 실패: {e}") + self.ml_predictor = None + else: + self.ml_predictor = None + + # News Analyzer 초기화 + self.use_news = os.environ.get("USE_NEWS_ANALYSIS", "false").lower() == "true" + self.news_hour = get_env_int("NEWS_ANALYSIS_HOUR", "9") + self.news_max = get_env_int("NEWS_MAX_COUNT", "5") + self.news_analyzed_today = False + if self.use_news: + try: + self.news_analyzer = NewsAnalyzer() + except Exception as e: + logger.warning(f"⚠️ News 초기화 실패: {e}") + self.news_analyzer = None + else: + self.news_analyzer = None + + # 구글 트렌드 괴리율 분석기 초기화 + try: + self.trend_analyzer = TrendDivergenceAnalyzer() + logger.info("✅ 구글 트렌드 괴리율 분석기 초기화 완료") + except Exception as e: + logger.warning(f"⚠️ 트렌드 분석기 초기화 실패: {e}") + self.trend_analyzer = None + + # 관세청 수출 호재 감지기 초기화 + try: + self.export_sniper = ExportSniper() + logger.info("✅ 관세청 수출 호재 감지기 초기화 완료") + except Exception as e: + logger.warning(f"⚠️ 수출 감지기 초기화 실패: {e}") + self.export_sniper = None + + # 뉴스 감시 스레드 상태 + self.news_monitor_running = False + self._news_monitor_task = None # asyncio.Task (스레드 제거) + + # 거래 설정 + self.max_stocks = get_env_int("MAX_STOCKS", "5") + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", "-0.035") + + # 🚨 리스크 관리 (손실 한도) + self.use_risk_check = os.environ.get("USE_RISK_CHECK", "true").lower() == "true" + self.daily_stop_loss_pct = get_env_float("DAILY_STOP_LOSS_PCT", "-0.05") + self.consecutive_loss_limit = get_env_int("CONSECUTIVE_LOSS_LIMIT", "4") + self.max_loss_per_trade_krw = get_env_int("MAX_LOSS_PER_TRADE_KRW", "200000") + + # 🚫 금지 종목 관리 (재매수 방지) + self.use_ban_system = os.environ.get("USE_BAN_SYSTEM", "true").lower() == "true" + self.ban_hours = get_env_int("BAN_HOURS", "24") + + # 🗑️ 종목 필터링 (쓰레기 종목 제외) + self.use_stock_filter = os.environ.get("USE_STOCK_FILTER", "true").lower() == "true" + + # 전략 파라미터 + self.rsi_threshold = get_env_float("RSI_OVERHEAT_THRESHOLD", "73") + self.shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", "0.03") + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO", "0.5") + self.max_recovery_ratio = get_env_float("MAX_RECOVERY_RATIO", "0.8") + # POP/LOCK 순수익 기반 익절 파라미터 (소수 단위) + # ROUND_TRIP_COST_PCT: 수수료+세금 왕복 비용 비율 (예: 0.0026 = 0.26%) + # POP_NET_PCT: POP 판단용 순수익 기준 (예: 0.05 = +5% 순수익 이상 갔던 경우만) + # LOCK_NET_PCT: 되돌림 시 지킬 최소 순수익 (예: 0.003 = +0.3% 순수익) + self.round_trip_cost_pct = get_env_float("ROUND_TRIP_COST_PCT", "0.0026") + self.pop_net_pct = get_env_float("POP_NET_PCT", "0.05") + self.lock_net_pct = get_env_float("LOCK_NET_PCT", "0.003") + + # 상태 변수 + self.current_cash = 0 + self.start_asset = 0 + self.current_total_asset = 0 + self.prev_session_asset = 0 # 이전 세션 자산 + self.start_day_asset = 0 # 당일 시작 자산 + self.today_date = datetime.datetime.now().strftime("%Y%m%d") + self.trading_halted = False + # 종목별 손익 경로 상태 (순수익 POP/LOCK 판단용, 프로세스 생명주기 내에서만 유지) + # { code: {"max_profit_pct": float, "min_profit_pct": float, "went_negative": bool} } + self.trade_state = {} + + # 분할 매수 진행 중인 종목 추적 (중복 실행 방지) — asyncio.Task 사용 (스레드 제거) + # { code: asyncio.Task }. split_buy_lock은 _run_async() 시작 시 생성 (이벤트 루프 필요) + self.active_split_buys = {} + self.split_buy_lock = None + + # 장 상태 관리 + self.was_market_open = False + self.is_first_run = True + self.trading_halted = False # 일일 손실 한도 or 연속 손절로 거래 중단 여부 + + # 디버그용: 장 상태 강제 오픈 (비장시간 테스트용) + # FORCE_MARKET_OPEN=true 이면 check_market_status 결과를 무시하고 항상 장이 열린 것으로 간주 + self.force_market_open = os.environ.get("FORCE_MARKET_OPEN", "false").lower() == "true" + + # 계좌 조회 실패 카운트 초기화 (안전성을 위해 여기서도 초기화) + if not hasattr(self, 'account_query_fail_count'): + self.account_query_fail_count = 0 + if not hasattr(self, 'max_account_fail_alert'): + self.max_account_fail_alert = get_env_int("MAX_ACCOUNT_FAIL_ALERT", "3") + + def get_env_snapshot(self): + """매도 시점 env 스냅샷 (trade_history INSERT / env_config / 백테스트·대시보드용). 비밀/토큰 제외.""" + return { + # 손절·목표 + "STOP_LOSS_PCT": os.environ.get("STOP_LOSS_PCT", "-0.035"), + "SHOULDER_CUT_PCT": os.environ.get("SHOULDER_CUT_PCT", "0.03"), + "STOP_ATR_MULTIPLIER_TAIL": os.environ.get("STOP_ATR_MULTIPLIER_TAIL", "3.5"), + "TARGET_ATR_MULTIPLIER_TAIL": os.environ.get("TARGET_ATR_MULTIPLIER_TAIL", "8.0"), + # 포지션·슬롯 + "MAX_POSITION_PCT": os.environ.get("MAX_POSITION_PCT", "0.20"), + "USE_SLOT_CAP": os.environ.get("USE_SLOT_CAP", "true"), + "SLOT_CAP_PCT": os.environ.get("SLOT_CAP_PCT", "0.9"), + "MAX_STOCKS": os.environ.get("MAX_STOCKS", "5"), + # 켈리·리스크 + "USE_KELLY": os.environ.get("USE_KELLY", "false"), + "RISK_PCT_PER_TRADE": os.environ.get("RISK_PCT_PER_TRADE", "0.02"), + "MIN_POSITION_AMOUNT": os.environ.get("MIN_POSITION_AMOUNT", "50000"), + # 리스크 한도 + "USE_RISK_CHECK": os.environ.get("USE_RISK_CHECK", "true"), + "DAILY_STOP_LOSS_PCT": os.environ.get("DAILY_STOP_LOSS_PCT", "-0.05"), + "CONSECUTIVE_LOSS_LIMIT": os.environ.get("CONSECUTIVE_LOSS_LIMIT", "4"), + "MAX_LOSS_PER_TRADE_KRW": os.environ.get("MAX_LOSS_PER_TRADE_KRW", "200000"), + # 금지 종목 + "USE_BAN_SYSTEM": os.environ.get("USE_BAN_SYSTEM", "true"), + "BAN_HOURS": os.environ.get("BAN_HOURS", "24"), + # 종목 필터·전략 + "USE_STOCK_FILTER": os.environ.get("USE_STOCK_FILTER", "true"), + "RSI_OVERHEAT_THRESHOLD": os.environ.get("RSI_OVERHEAT_THRESHOLD", "73"), + "MIN_RECOVERY_RATIO": os.environ.get("MIN_RECOVERY_RATIO", "0.5"), + "MAX_RECOVERY_RATIO": os.environ.get("MAX_RECOVERY_RATIO", "0.8"), + # TWAP + "USE_TWAP": os.environ.get("USE_TWAP", "false"), + "TWAP_MIN_SPLIT": os.environ.get("TWAP_MIN_SPLIT", "500000"), + "TWAP_MAX_SPLIT": os.environ.get("TWAP_MAX_SPLIT", "2000000"), + "TWAP_MIN_DELAY": os.environ.get("TWAP_MIN_DELAY", "30"), + "TWAP_MAX_DELAY": os.environ.get("TWAP_MAX_DELAY", "180"), + # ML·뉴스 + "USE_ML_SIGNAL": os.environ.get("USE_ML_SIGNAL", "false"), + "ML_MIN_PROBABILITY": os.environ.get("ML_MIN_PROBABILITY", "0.65"), + "USE_NEWS_ANALYSIS": os.environ.get("USE_NEWS_ANALYSIS", "false"), + "NEWS_ANALYSIS_HOUR": os.environ.get("NEWS_ANALYSIS_HOUR", "9"), + "NEWS_MAX_COUNT": os.environ.get("NEWS_MAX_COUNT", "5"), + # 스캔·필터 + "USE_QUICK_PROFIT_PROTECTION": os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true"), + "HIGH_PRICE_CHASE_THRESHOLD": os.environ.get("HIGH_PRICE_CHASE_THRESHOLD", "0.96"), + "MAX_DAILY_CHANGE_PCT": os.environ.get("MAX_DAILY_CHANGE_PCT", "20.0"), + "MA20_MAX_ABOVE_PCT": os.environ.get("MA20_MAX_ABOVE_PCT", "3.0"), + "VOLUME_AVG_MULTIPLIER": os.environ.get("VOLUME_AVG_MULTIPLIER", "1.0"), + "CANDLE_OPEN_PRICE_BUFFER": os.environ.get("CANDLE_OPEN_PRICE_BUFFER", "0.995"), + "INTRADAY_INVESTOR_NET_BUY_THRESHOLD": os.environ.get("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000"), + # 대중소형 + "SIZE_CLASS_LARGE_MIN": os.environ.get("SIZE_CLASS_LARGE_MIN", "5000000000"), + "SIZE_CLASS_MID_MIN": os.environ.get("SIZE_CLASS_MID_MIN", "500000000"), + # 기타 + "USE_RANDOM_SPLIT": os.environ.get("USE_RANDOM_SPLIT", "true"), + "FORCE_MARKET_OPEN": os.environ.get("FORCE_MARKET_OPEN", "false"), + "TOTAL_DEPOSIT": os.environ.get("TOTAL_DEPOSIT", "0"), + } + + # 리포트 플래그 + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.news_analyzed_today = False + + # 계좌 조회 실패 추적 + self.account_query_fail_count = 0 + self.max_account_fail_alert = 3 # 3회 연속 실패 시 알림 + + # 총 입금액 (누적 손익률 계산용) + self.total_deposit = get_env_float("TOTAL_DEPOSIT", "0") + + # 봇 상태 파일 + self.bot_state_file = os.path.join(current_dir, 'bot_state.json') + + # 이전 세션 상태 로드 + self._load_bot_state() + + # 초기 계좌 정보 로드 + self.refresh_account() + + # JSON 마이그레이션 (최초 1회) + self._migrate_from_json_if_needed() + + # 관리자 웹에서 저장한 최신 설정을 DB에서 불러와 런타임에 적용 + # (UPDATE/스냅샷 찍기 아님: "DB 최신값 -> 봇 설정" 방향) + try: + latest = self.db.get_latest_env() + if latest and isinstance(latest.get("snapshot"), dict) and latest["snapshot"]: + applied = 0 + for k, v in latest["snapshot"].items(): + # os.environ은 str만 저장 가능 + os.environ[str(k)] = "" if v is None else str(v) + applied += 1 + logger.info( + f"✅ DB env_config 최신값 적용: id={latest.get('id')} keys={applied}" + ) + else: + logger.info("ℹ️ DB env_config 최신값 없음: .env/os.environ 사용") + except Exception as e: + logger.warning(f"⚠️ DB env_config 적용 실패: {e} (계속 .env/os.environ 사용)") + + # 시작 메시지 + self._send_startup_message(kelly_enabled, use_twap) + + def _load_bot_state(self): + """이전 실행 시 봇 상태 로드""" + try: + if os.path.exists(self.bot_state_file): + with open(self.bot_state_file, 'r', encoding='utf-8') as f: + state = json.load(f) + + self.prev_session_asset = float(state.get('start_equity', 0)) + prev_day = state.get('start_day', '') + + if prev_day != self.today_date: + # 새로운 날 + logger.info(f"📅 새로운 거래일: {self.today_date}") + self.start_day_asset = 0 # 나중에 refresh_account에서 설정 + else: + # 같은 날 재시작 + self.start_day_asset = float(state.get('start_day_asset', 0)) + logger.info(f"🔄 봇 재시작 (당일 시작: {self.start_day_asset:,.0f}원)") + else: + logger.info("📝 최초 실행 - 새로운 bot_state.json 생성") + except Exception as e: + logger.error(f"⚠️ 봇 상태 로드 실패: {e}") + + def _save_bot_state(self): + """현재 봇 상태 저장""" + try: + state = { + 'start_equity': self.current_total_asset, + 'start_day': self.today_date, + 'start_day_asset': self.start_day_asset, + 'last_update': datetime.datetime.now().isoformat() + } + with open(self.bot_state_file, 'w', encoding='utf-8') as f: + json.dump(state, f, indent=2, ensure_ascii=False) + except Exception as e: + logger.error(f"⚠️ 봇 상태 저장 실패: {e}") + + def _send_startup_message(self, kelly_enabled, use_twap): + """봇 시작 메시지 전송""" + # 세션 대비 손익률 + session_pnl_pct = 0.0 + if self.prev_session_asset > 0: + session_pnl_pct = ((self.current_total_asset - self.prev_session_asset) / self.prev_session_asset) * 100 + + # 당일 손익률 + day_pnl_pct = 0.0 + if self.start_day_asset > 0: + day_pnl_pct = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset) * 100 + + # 누적 손익률 (총 입금액 대비) + cumulative_pnl_pct = 0.0 + if self.total_deposit > 0: + cumulative_pnl_pct = ((self.current_total_asset - self.total_deposit) / self.total_deposit) * 100 + + # 계좌 정보가 0원으로 떨어진 경우 보호 로직 + # (예: API 장애 / 로그인 실패 등으로 예수금·자산을 못 가져온 경우) + if self.current_total_asset <= 0: + logger.warning( + "⚠️ 계좌 자산이 0원으로 조회되었습니다. " + "API 오류 / 네트워크 문제 가능성이 있어 시작 손익률을 0%로 표시합니다." + ) + session_pnl_pct = 0.0 + day_pnl_pct = 0.0 + cumulative_pnl_pct = 0.0 + + # 🚨 피뢰침 방지 필터 정보 + use_quick_profit = os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true").lower() == "true" + high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", "0.96") + max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", "20.0") + + msg = f"""🤖 **[트레이딩봇 Ver2 가동]** +- 현재 자산: {self.current_total_asset:,.0f}원 +- 세션 손익: {session_pnl_pct:+.2f}% +- 당일 손익: {day_pnl_pct:+.2f}% +- 누적 손익: {cumulative_pnl_pct:+.2f}% (입금액: {self.total_deposit:,.0f}원) +- DB 기반 안전 관리 +- 변동성 기반 자금 관리 +- TWAP: {'ON' if use_twap else 'OFF'} +- 켈리 공식: {'ON' if kelly_enabled else 'OFF'} +- ML 필터: {'ON' if getattr(self, 'use_ml', False) else 'OFF'} (임계값: {getattr(self, 'ml_min_prob', 0.0):.2f}) +- 뉴스 분석: {'ON' if getattr(self, 'use_news', False) else 'OFF'} +- 🛑 리스크 체크: {'ON' if self.use_risk_check else 'OFF'} (일일: {self.daily_stop_loss_pct*100:.1f}%, 연속: {self.consecutive_loss_limit}회) +- 🚫 금지 종목: {'ON' if self.use_ban_system else 'OFF'} ({self.ban_hours}시간) +- 🗑️ 종목 필터: {'ON' if self.use_stock_filter else 'OFF'} +- 💨 작은수익보호: {'ON' if use_quick_profit else 'OFF'} (30분 내) +- ⚡ 피뢰침 방지: ON (고점 {(1-high_chase_threshold)*100:.0f}%+ 조정 | 급등 {max_daily_change:.0f}% 제외)""" + + logger.info(msg) + self.send_mm(msg) + + def _migrate_from_json_if_needed(self): + """기존 JSON 파일에서 DB로 마이그레이션 (1회성)""" + portfolio_file = os.path.join(current_dir, 'portfolio.json') + if os.path.exists(portfolio_file): + try: + with open(portfolio_file, 'r', encoding='utf-8') as f: + data = json.load(f) + if data: + count = self.db.migrate_from_json(data) + logger.info(f"📦 JSON 마이그레이션 완료: {count}개 종목") + # 백업 후 삭제 + backup_path = portfolio_file + ".backup" + os.rename(portfolio_file, backup_path) + logger.info(f"💾 기존 JSON 백업: {backup_path}") + except Exception as e: + logger.error(f"❌ JSON 마이그레이션 실패: {e}") + + def send_mm(self, msg): + """Mattermost 알림 전송""" + try: + self.mm.send(self.mm_channel, msg) + except Exception as e: + logger.error(f"❌ MM 전송 에러: {e}") + + def send_morning_report(self): + """오전 장 뜸할 때 리포트 (13:00)""" + if self.morning_report_sent: + return + + day_pnl = self.current_total_asset - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + msg = f"""📊 **[오전 장 현황 - 13:00]** +- 당일 시작: {self.start_day_asset:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 보유 종목: {len(self.db.get_active_trades())}개""" + + self.send_mm(msg) + self.morning_report_sent = True + logger.info("📊 오전 리포트 전송 완료") + + def send_closing_report(self): + """장마감 전 리포트 (15:15)""" + if self.closing_report_sent: + return + + day_pnl = self.current_total_asset - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + msg = f"""📈 **[장마감 전 현황 - 15:15]** +- 당일 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) +- 현재 자산: {self.current_total_asset:,.0f}원 +- 보유 종목: {len(self.db.get_active_trades())}개 +- 예수금: {self.current_cash:,.0f}원""" + + self.send_mm(msg) + self.closing_report_sent = True + logger.info("📈 장마감 전 리포트 전송 완료") + + def send_final_report(self): + """장마감 후 최종 리포트 (15:35)""" + if self.final_report_sent: + return + + # 당일 손익 + day_pnl = self.current_total_asset - self.start_day_asset + day_pnl_pct = (day_pnl / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + # 누적 손익 + cumulative_pnl = self.current_total_asset - self.total_deposit + cumulative_pnl_pct = (cumulative_pnl / self.total_deposit * 100) if self.total_deposit > 0 else 0 + + # 오늘 거래 내역 + today_trades = self.db.get_trades_by_date(self.today_date) + + msg = f"""🏁 **[장마감 최종 보고 - 15:35]** +━━━━━━━━━━━━━━━━━━━━ +📅 **당일 손익** +- 시작: {self.start_day_asset:,.0f}원 +- 종료: {self.current_total_asset:,.0f}원 +- 손익: {day_pnl:+,.0f}원 ({day_pnl_pct:+.2f}%) + +💰 **누적 손익 (총 입금액 대비)** +- 총 입금: {self.total_deposit:,.0f}원 +- 현재 자산: {self.current_total_asset:,.0f}원 +- 누적 손익: {cumulative_pnl:+,.0f}원 ({cumulative_pnl_pct:+.2f}%) + +📊 **거래 현황** +- 오늘 매매: {len(today_trades)}건 +- 보유 종목: {len(self.db.get_active_trades())}개 +- 예수금: {self.current_cash:,.0f}원 +━━━━━━━━━━━━━━━━━━━━""" + + self.send_mm(msg) + self.final_report_sent = True + logger.info("🏁 장마감 최종 리포트 전송 완료") + + def analyze_and_send_news(self): + """뉴스 AI 분석 및 Mattermost 알림""" + if self.news_analyzed_today: + return + + logger.info("📰 [뉴스 분석] 시작") + + try: + news_list = self.news_analyzer.crawl_naver_finance_news(max_news=self.news_max) + + if not news_list: + logger.warning("⚠️ 크롤링된 뉴스 없음") + self.news_analyzed_today = True + return + + analysis = self.news_analyzer.analyze_news_with_claude(news_list) + + if not analysis: + logger.warning("⚠️ AI 분석 실패") + self.news_analyzed_today = True + return + + message = self.news_analyzer.format_analysis_for_mattermost(analysis, news_list) + + if message: + self.send_mm(message) + logger.info("✅ 뉴스 분석 알림 전송 완료") + + self.news_analyzed_today = True + + except Exception as e: + logger.error(f"❌ 뉴스 분석 중 에러: {e}") + self.news_analyzed_today = True + + def refresh_account(self): + """계좌 정보 갱신 (전체 조회 - 5분마다)""" + # 안전성: 속성이 없으면 즉시 초기화 (초기화 순서 문제 방지) + if not hasattr(self, 'account_query_fail_count'): + self.account_query_fail_count = 0 + if not hasattr(self, 'max_account_fail_alert'): + self.max_account_fail_alert = get_env_int("MAX_ACCOUNT_FAIL_ALERT", "3") + + try: + info = self.api.get_account_info() + + # API 응답 검증 + if not info: + if not hasattr(self, 'account_query_fail_count'): + self.account_query_fail_count = 0 + self.account_query_fail_count += 1 + logger.error(f"❌ 계좌 조회 실패 ({self.account_query_fail_count}회 연속) - API 응답 없음 | 이전 값 유지: {self.current_total_asset:,.0f}원") + + # 3회 연속 실패 시 알림 + if self.account_query_fail_count >= self.max_account_fail_alert: + try: + self.send_mm( + f"🚨 **[계좌 조회 실패 경고]**\n" + f"- 연속 실패: {self.account_query_fail_count}회\n" + f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}원\n" + f"- API 상태를 확인하세요!" + ) + except: + pass + return + + # 0원 응답 검증 (초기화 제외) + if info.get('total_asset', 0) <= 0 and self.current_total_asset > 0: + if not hasattr(self, 'account_query_fail_count'): + self.account_query_fail_count = 0 + self.account_query_fail_count += 1 + logger.error( + f"❌ 계좌 조회 이상 ({self.account_query_fail_count}회 연속) - " + f"응답: 예수금={info.get('deposit', 0):,.0f}원, 총자산={info.get('total_asset', 0):,.0f}원 | " + f"이전 값 유지: {self.current_total_asset:,.0f}원" + ) + + # 3회 연속 실패 시 알림 + if self.account_query_fail_count >= self.max_account_fail_alert: + try: + self.send_mm( + f"🚨 **[계좌 조회 이상 경고]**\n" + f"- 연속 실패: {self.account_query_fail_count}회\n" + f"- API 응답: 0원 (비정상)\n" + f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}원\n" + f"- 토큰 만료 or API 장애 가능성" + ) + except: + pass + return + + # ✅ 정상 응답 - 값 업데이트 + self.current_cash = info['deposit'] + self.current_total_asset = info['total_asset'] + + # 성공 시 실패 카운트 리셋 + if hasattr(self, 'account_query_fail_count') and self.account_query_fail_count > 0: + logger.info(f"✅ 계좌 조회 복구 (이전 {self.account_query_fail_count}회 실패)") + self.account_query_fail_count = 0 + + # 첫 실행 시 시작 자산 설정 + if self.start_asset == 0: + self.start_asset = info['total_asset'] + + # 당일 시작 자산 설정 (최초 1회) + if self.start_day_asset == 0: + self.start_day_asset = info['total_asset'] + + # 상태 저장 + self._save_bot_state() + + logger.info( + f"💰 예수금: {self.current_cash:,.0f}원 | " + f"총자산: {info['total_asset']:,.0f}원 " + f"(예수금 {self.current_cash:,.0f} + 주식 {info['stock_value']:,.0f})" + ) + + except Exception as e: + if not hasattr(self, 'account_query_fail_count'): + self.account_query_fail_count = 0 + self.account_query_fail_count += 1 + logger.error(f"❌ 계좌 정보 갱신 예외 ({self.account_query_fail_count}회 연속): {e} | 이전 값 유지") + + # 3회 연속 실패 시 알림 + if self.account_query_fail_count >= self.max_account_fail_alert: + try: + self.send_mm( + f"🚨 **[계좌 조회 예외 경고]**\n" + f"- 연속 실패: {self.account_query_fail_count}회\n" + f"- 에러: {str(e)[:100]}\n" + f"- 현재 유지 중인 값: {self.current_total_asset:,.0f}원" + ) + except: + pass + + def get_banned_codes(self): + """금지 종목 리스트 반환 (손절 후 재매수 방지)""" + # 🚫 금지 시스템이 꺼져있으면 빈 리스트 반환 + if not self.use_ban_system: + return [] + + try: + banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json') + if not os.path.exists(banned_file): + return [] + + with open(banned_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + active = [] + now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + for code, expire_time in data.items(): + if expire_time > now: + active.append(code) + return active + except Exception as e: + logger.error(f"❌ 금지 종목 조회 실패: {e}") + return [] + + def add_ban(self, code, name): + """종목을 금지 리스트에 추가 (손절 후 재매수 방지)""" + # 🚫 금지 시스템이 꺼져있으면 아무것도 안 함 + if not self.use_ban_system: + return + + try: + banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json') + + # 기존 데이터 로드 + if os.path.exists(banned_file): + with open(banned_file, 'r', encoding='utf-8') as f: + data = json.load(f) + else: + data = {} + + # 만료 시각 설정 (self.ban_hours 사용!) + expire_time = (datetime.datetime.now() + datetime.timedelta(hours=self.ban_hours)).strftime('%Y-%m-%d %H:%M:%S') + data[code] = expire_time + + # 저장 + with open(banned_file, 'w', encoding='utf-8') as f: + json.dump(data, f, ensure_ascii=False, indent=2) + + logger.warning(f"🚫 [매수 금지 추가] {name} ({code}): {self.ban_hours}시간 동안 재매수 불가") + + except Exception as e: + logger.error(f"❌ 금지 종목 추가 실패: {e}") + + def cleanup_banned_list(self): + """만료된 금지 종목 정리""" + try: + banned_file = os.path.join(os.path.dirname(__file__), 'banned_codes.json') + if not os.path.exists(banned_file): + return + + with open(banned_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + new_data = {k: v for k, v in data.items() if v > now} + + if len(data) != len(new_data): + with open(banned_file, 'w', encoding='utf-8') as f: + json.dump(new_data, f, ensure_ascii=False, indent=2) + logger.info(f"🧹 [금지 종목 정리] {len(data) - len(new_data)}개 만료 제거") + + except Exception as e: + logger.error(f"❌ 금지 종목 정리 실패: {e}") + + def _is_valid_stock(self, name, code): + """ + 종목 필터링 (Dual 로직 이식!) + - 스팩, ETN, 우선주, 레버리지, 인버스 등 제외 + - "쓰레기 같은걸 자꾸 사는" 문제 방지! + """ + # 🗑️ 필터링이 꺼져있으면 모두 통과 + if not self.use_stock_filter: + return True + + try: + # 1. 코드 유효성 검사 + if len(code) != 6 or not code.isdigit(): + return False + + # 2. 제외 키워드 검사 + exclude_keywords = [ + '스팩', 'SPAC', 'ETN', 'W', 'ELW', '채권', + '레버리지', '인버스', '곱버스', + '선물', '콜', '풋', + '2X', '3X', '합성', 'H', 'B' + ] + + for keyword in exclude_keywords: + if keyword in name: + logger.info(f"🔍 [Pass-필터] {name} ({code}): '{keyword}' 포함 → 제외") + return False + + # 3. 우선주 제외 + if name.endswith('우') or name.endswith('우B'): + logger.info(f"🔍 [Pass-필터] {name} ({code}): 우선주 → 제외") + return False + + return True + + except Exception as e: + logger.error(f"❌ 종목 필터링 실패: {e}") + return True # 예외 시 통과 (보수적) + + def check_risk_status(self): + """ + 일일 손실 한도 체크 (Dual 로직 이식!) + - 하루 손실 한도 도달 시 거래 중단 + - 연속 손절 한도 도달 시 거래 중단 + """ + # 🛑 리스크 체크가 꺼져있으면 아무것도 안 함 + if not self.use_risk_check: + return False + + try: + # 1. 일일 수익률 계산 + if self.start_day_asset > 0: + daily_profit_pct = (self.current_total_asset - self.start_day_asset) / self.start_day_asset + else: + daily_profit_pct = 0 + + # 2. 일일 손실 한도 체크 (self.daily_stop_loss_pct 사용!) + if daily_profit_pct <= self.daily_stop_loss_pct and not self.trading_halted: + self.trading_halted = True + msg = ( + f"🛑 **[STOP LOSS 발동]**\n" + f"일일 손실률: {daily_profit_pct * 100:.2f}%\n" + f"기준: -5.0%\n" + f"→ 금일 매수 중단!" + ) + logger.critical(msg) + self.send_mm(msg) + return True + + # 3. 연속 손절 체크 (self.consecutive_loss_limit 사용!) + # DB에서 최근 N개 거래 조회 + recent_trades = self.db.conn.execute(f""" + SELECT sell_reason, profit_rate + FROM trade_history + ORDER BY id DESC + LIMIT {self.consecutive_loss_limit} + """).fetchall() + + if len(recent_trades) >= self.consecutive_loss_limit: + # 모두 손실이고 손절 사유인지 확인 + all_stop_loss = all( + reason and ("손절" in reason or "어깨" in reason) and profit < 0 + for reason, profit in recent_trades + ) + + if all_stop_loss and not self.trading_halted: + self.trading_halted = True + msg = ( + f"🛑 **[연속 손절 과다]**\n" + f"최근 {self.consecutive_loss_limit}회 연속 손절 발생\n" + f"→ 금일 매수 중단!" + ) + logger.critical(msg) + self.send_mm(msg) + return True + + return False + + except Exception as e: + logger.error(f"❌ 리스크 상태 체크 실패: {e}") + return False + + def sync_portfolio_from_api(self): + """ + 실제 계좌 보유 종목 ↔ DB 동기화 (Dual 로직 이식!) + - 실제 계좌에는 있지만 DB에 없는 종목 → RECOVERED 전략으로 추가 + - DB에는 있지만 실제 계좌에 없는 종목 → DB에서 제거 + """ + try: + logger.info("🔄 [실계좌↔DB 동기화 시작]") + + # 1. 실제 계좌 보유 종목 조회 + info = self.api.get_account_info() + if not info or not info.get('holdings'): + logger.warning("⚠️ 실제 계좌 보유 종목 조회 실패") + return + + real_holdings = info['holdings'] # {code: {name, qty, buy_price, current_price, ...}} + db_trades = self.db.get_active_trades() + + real_codes = set(real_holdings.keys()) + db_codes = set(db_trades.keys()) + + # 2. 실제에는 있지만 DB에 없는 종목 (RECOVERED 전략으로 추가) + missing_in_db = real_codes - db_codes + if missing_in_db: + logger.warning(f"⚠️ DB에 없는 종목 {len(missing_in_db)}개 발견!") + for code in missing_in_db: + stock = real_holdings[code] + name = stock['name'] + qty = stock['qty'] + buy_price = stock['buy_price'] + current_price = stock['current_price'] + + # ATR 복구 시도 + atr = buy_price * 0.01 # 기본값 + try: + df = self.api.get_ohlcv_limit(code, timeframe='1m', limit=100) + if df is not None and not df.empty: + atr = self.calculate_atr(df) + except Exception as e: + logger.error(f"❌ [{name}] ATR 계산 실패: {e}") + + # DB에 추가 + now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + trade_data = { + 'code': code, + 'name': name, + 'avg_buy_price': buy_price, + 'stop_price': buy_price - (atr * 3.0), + 'target_price': buy_price + (atr * 5.0), + 'atr_entry': atr, + 'target_qty': qty, + 'current_qty': qty, + 'total_invested': buy_price * qty, + 'status': 'HOLDING', + 'strategy': 'RECOVERED', + 'buy_date': now_str, + 'max_price': current_price + } + + self.db.upsert_trade(trade_data) + logger.info(f"♻️ [RECOVERED] {name} ({code}): {qty}주 @ {buy_price:,.0f}원 → DB 추가") + + # Mattermost 알림 + self.send_mm( + f"♻️ **[DB 복구]** {name}\n" + f"수량: {qty}주 @ {buy_price:,.0f}원\n" + f"전략: RECOVERED (실제 계좌에서 발견)" + ) + + # 3. DB에는 있지만 실제에는 없는 종목 (자동 매도된 종목, DB 정리) + missing_in_real = db_codes - real_codes + if missing_in_real: + logger.warning(f"⚠️ 실제 계좌에 없는 종목 {len(missing_in_real)}개 발견!") + for code in missing_in_real: + trade = db_trades[code] + name = trade['name'] + buy_price = trade['avg_buy_price'] + + # 현재가 조회 (최종 매도가 추정) + current_price = self.api.get_current_price(code) + sell_price = current_price if current_price else buy_price + + # DB에서 제거 (강제 매도 처리) + env_snapshot = json.dumps(self.get_env_snapshot(), ensure_ascii=False) + self.db.close_trade(code, sell_price, "동기화_정리(실제계좌없음)", env_snapshot=env_snapshot, size_class=trade.get('size_class')) + logger.info(f"🗑️ [DB 정리] {name} ({code}): 실제 계좌에 없음 → DB 제거") + + # Mattermost 알림 + self.send_mm( + f"🗑️ **[DB 정리]** {name}\n" + f"사유: 실제 계좌에 없음\n" + f"추정 매도가: {sell_price:,.0f}원" + ) + + # 4. 공통 종목의 수량/평단 동기화 (★ 체결 기준으로 정합성 맞추기) + common_codes = real_codes & db_codes + for code in common_codes: + real = real_holdings[code] + trade = db_trades[code] + + real_qty = real['qty'] + db_qty = trade['current_qty'] + + real_buy = float(real.get('buy_price', 0.0)) + db_buy = float(trade.get('avg_buy_price') or trade.get('buy_price') or 0.0) + + real_cur = float(real.get('current_price', 0.0)) + + qty_mismatch = real_qty != db_qty + buy_mismatch = abs(real_buy - db_buy) > 1e-4 + + if qty_mismatch or buy_mismatch: + name = trade['name'] + logger.warning( + f"⚠️ [{name}] 포지션 불일치: " + f"수량 DB={db_qty} vs 실계좌={real_qty}, " + f"평단 DB={db_buy:.2f} vs 실계좌={real_buy:.2f}" + ) + + # --- STOP/TARGET 재계산 (실제 평단 기준) --- + old_stop = float(trade.get('stop_price') or 0.0) + old_target = float(trade.get('target_price') or 0.0) + atr = float(trade.get('atr_at_entry') or trade.get('atr_entry') or 0.0) + + new_stop = old_stop + new_target = old_target + + if atr > 0: + # env에서 ATR 배수 가져와서 새 평단 기준으로 재계산 + atr_multiplier_stop = get_env_float("STOP_ATR_MULTIPLIER_TAIL", "3.5") + atr_multiplier_target = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", "8.0") + new_stop = real_buy - (atr * atr_multiplier_stop) + new_target = real_buy + (atr * atr_multiplier_target) + else: + # ATR이 없으면 기존 거리(평단~STOP/TARGET)를 유지한 채 평단만 옮김 + if old_stop > 0 and old_target > 0 and db_buy > 0: + stop_delta = db_buy - old_stop # 평단~손절 거리 + target_delta = old_target - db_buy # 평단~목표 거리 + new_stop = real_buy - stop_delta + new_target = real_buy + target_delta + + total_invested = real_buy * real_qty + + # active_trades를 실계좌 기준으로 동기화 (체결 기준) + with self.db.conn: + self.db.conn.execute( + """ + UPDATE active_trades + SET + current_qty = ?, + target_qty = ?, + avg_buy_price = ?, + total_invested = ?, + current_price = ?, + max_price = MAX(max_price, ?), + stop_price = ?, + target_price = ? + WHERE code = ? + """, + (real_qty, real_qty, real_buy, total_invested, + real_cur, real_cur, + new_stop, new_target, + code), + ) + + logger.info( + f"✅ [{name}] 동기화 완료: " + f"수량 {db_qty}→{real_qty}주, " + f"평단 {db_buy:.2f}→{real_buy:.2f}, " + f"투입금 {trade.get('total_invested', 0):,.0f}→{total_invested:,.0f}원" + ) + + logger.info(f"✅ [동기화 완료] 실제:{len(real_codes)}개 | DB:{len(db_codes)}개 | 공통:{len(common_codes)}개") + + except Exception as e: + logger.error(f"❌ 계좌↔DB 동기화 실패: {e}") + import traceback + logger.error(f"상세 오류:\n{traceback.format_exc()}") + + def sync_order_execution_from_api(self): + """kt00007 / ka10076 체결 내역 보강용 INSERT (2초 간격)""" + try: + today_ymd = datetime.datetime.now().strftime("%Y%m%d") + # kt00007: 계좌별 주문·체결 내역 (체결내역만) + res = self.api._safe_request( + self.api.account.account_order_execution_detail_request_kt00007, + "4", "1", "0", "%", + ord_dt=today_ymd, + ) + if res and res.get("acnt_ord_cntr_prps_dtl"): + for row in res["acnt_ord_cntr_prps_dtl"]: + self.db.insert_order_execution("kt00007", row, ord_dt=today_ymd, sell_tp="0") + logger.info(f"📥 [주문보강] kt00007 {len(res['acnt_ord_cntr_prps_dtl'])}건 저장") + time.sleep(2) + # ka10076: 체결 요청 + res2 = self.api._safe_request( + self.api.account.filled_orders_request_ka10076, + "0", "0", "0", + ) + if res2 and res2.get("cntr"): + for row in res2["cntr"]: + self.db.insert_order_execution("ka10076", row, sell_tp="0") + logger.info(f"📥 [주문보강] ka10076 {len(res2['cntr'])}건 저장") + except Exception as e: + logger.debug(f"주문 보강 조회 실패: {e}") + + def cancel_stale_orders(self, max_age_seconds: int = 10): + """ + 오늘자 미체결 주문 중 N초 이상 지난 잔량은 전량 취소. + - 단타 목적이 아니므로, 오래 안 체결되면 '안 되면 말고' 철학으로 정리. + - 기준: kt00007 (계좌별 주문·체결 내역)에서 ord_qty > cntr_qty 인 주문. + """ + try: + today_ymd = datetime.datetime.now().strftime("%Y%m%d") + now = datetime.datetime.now() + + res = self.api._safe_request( + self.api.account.account_order_execution_detail_request_kt00007, + "4", "1", "0", "%", # (키움 TR 문서 기준 파라미터) + ord_dt=today_ymd, + ) + if not res or not res.get("acnt_ord_cntr_prps_dtl"): + return + + for row in res["acnt_ord_cntr_prps_dtl"]: + ord_no = row.get("ord_no") or row.get("orig_ord_no") + stk_cd = row.get("stk_cd") + ord_qty = int(str(row.get("ord_qty", "0") or "0")) + cntr_qty = int(str(row.get("cntr_qty", "0") or "0")) + ord_tm = row.get("ord_tm", "") # "HHMMSS" + + # 전량 체결 또는 주문수량 0이면 스킵 + if ord_qty <= 0 or cntr_qty >= ord_qty: + continue + + # 주문 시각 → datetime + if len(ord_tm) == 6: + h, m, s = int(ord_tm[0:2]), int(ord_tm[2:4]), int(ord_tm[4:6]) + ord_time = now.replace(hour=h, minute=m, second=s, microsecond=0) + else: + continue + + age = (now - ord_time).total_seconds() + if age < max_age_seconds: + continue # 아직 기다릴 시간 + + logger.warning( + f"🚫 [미체결 취소 후보] {stk_cd} 주문번호 {ord_no} | " + f"주문수량={ord_qty}, 체결수량={cntr_qty}, 잔량={ord_qty-cntr_qty}, " + f"경과 {age:.1f}s" + ) + + cancel_res = self.api._safe_request( + self.api.order.stock_cancel_order_request_kt10003, + "KRX", + orig_ord_no=ord_no, + stk_cd=stk_cd, + cncl_qty="0", # 잔량 전부 취소 + ) + if isinstance(cancel_res, dict) and str(cancel_res.get("return_code", 0)) == "0": + logger.info(f"✅ [취소 성공] {stk_cd} 주문번호 {ord_no}") + else: + logger.error(f"❌ [취소 실패] {stk_cd} 주문번호 {ord_no} | 응답: {cancel_res}") + except Exception as e: + logger.error(f"❌ 미체결 취소 처리 실패: {e}") + + def update_account_light(self, profit_val=0): + """ + 경량 계좌 갱신 (매수/매도 직후 즉시 호출!) + - API 부하를 줄이기 위해 예수금만 빠르게 조회 + - 총자산은 로컬 계산으로 추정 + """ + try: + new_cash = self.api.get_deposit_only() + if new_cash > 0 or self.current_cash == 0: + self.current_cash = new_cash + + # 손익 반영 (매도 시) + if profit_val != 0: + self.current_total_asset += profit_val + + logger.debug(f"💵 [경량갱신] 예수금: {self.current_cash:,.0f}원") + except Exception as e: + logger.error(f"❌ 경량 갱신 실패: {e}") + + def calculate_rsi(self, df, period=14): + """RSI 계산""" + try: + if df is None or len(df) < period + 1: + return 50 + + delta = df['close'].diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + + rs = gain / loss + rsi = 100 - (100 / (1 + rs)) + return rsi.iloc[-1] if not np.isnan(rsi.iloc[-1]) else 50 + except: + return 50 + + def calculate_atr(self, df, period=14): + """ATR 계산""" + try: + if df is None or len(df) < period: + return 0 + + df = df.copy() + df['tr'] = np.maximum( + df['high'] - df['low'], + np.maximum( + np.abs(df['high'] - df['close'].shift()), + np.abs(df['low'] - df['close'].shift()) + ) + ) + atr = df['tr'].rolling(window=period).mean().iloc[-1] + return atr if not np.isnan(atr) else 0 + except: + return 0 + + def check_buy_signal_tail_catch(self, code, name): + """ + TAIL_CATCH_3M 전략: 3분봉 꼬리 잡기 (kiwoom_trader_dual.py 정식 버전) + - RSI 과열 체크 + 고가 추격 방지 + 꼬리 필터 강화 + 상세 로그 + """ + try: + # 🚨 0. 거래 중단 체크 (일일 손실 한도 or 연속 손절) + if self.trading_halted: + logger.info(f"🔍 [Pass-거래중단] {name} {code}: STOP LOSS 발동 중") + return None + + # 🚨 1. 금지 종목 체크 (손절 후 24시간 재매수 방지!) + if code in self.get_banned_codes(): + logger.info(f"🔍 [Pass-금지종목] {name} {code}: 손절 후 24시간 내 재매수 불가") + return None + + # 🚨 2. 종목 필터링 (스팩, ETN, 레버리지 등 제외!) + if not self._is_valid_stock(name, code): + return None + + # 2. 데이터 조회 + df = self.api.get_ohlcv(code, timeframe='3m', limit=50) + + # 데이터 유효성 검사 + if df is None or len(df) < 20: + logger.info(f"🔍 [데이터 부족] {name} {code}: DF 길이 {len(df) if df is not None else 0}") + return None + + # 3. 지표 계산 + ma20 = df['close'].rolling(window=20).mean().iloc[-1] + ma60 = df['close'].rolling(window=60).mean().iloc[-1] if len(df) >= 60 else ma20 + avg_vol = df['volume'].rolling(window=20).mean().iloc[-1] + current_price = df['close'].iloc[-1] + current_vol = df['volume'].iloc[-1] + + # --------------------------------------------------------- + # [필터 0] MA60 골든크로스 체크 (추가) + # MA20이 MA60을 상향 돌파 = 단타 타이밍 개선 + if len(df) >= 60: + prev_ma20 = df['close'].rolling(window=20).mean().iloc[-2] + prev_ma60 = df['close'].rolling(window=60).mean().iloc[-2] + is_golden_cross = (ma20 > ma60) and (prev_ma20 <= prev_ma60) + if is_golden_cross: + logger.info(f"✨ [골든크로스] {name} {code}: MA20({ma20:.1f}) > MA60({ma60:.1f}) 상향 돌파 감지") + # 골든크로스가 아니어도 MA20 > MA60이면 통과 (추세 상승) + if ma20 < ma60: + logger.info(f"🔍 [Pass-MA60] {name} {code}: MA20({ma20:.1f}) < MA60({ma60:.1f}) 하락 추세") + return None + + # --------------------------------------------------------- + # [필터 1] RSI 과열 체크 + rsi = self.calculate_rsi(df) + if rsi >= self.rsi_threshold: + logger.info(f"🔍 [Pass-RSI] {name} {code}: RSI 과열 ({rsi:.1f} >= {self.rsi_threshold})") + return None + + # [필터 2] 🚨 피뢰침 방지 - 고점 추격 매수 방지 + daily_high = df['high'].max() + high_chase_threshold = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", "0.96") # 고점 대비 4% 이상 조정 + if current_price >= daily_high * high_chase_threshold: + drop_from_high = (daily_high - current_price) / daily_high * 100 + logger.info(f"🔍 [Pass-피뢰침] {name} {code}: 고점 대비 {drop_from_high:.1f}% 조정 부족 (최소 4% 필요)") + return None + + # [필터 2-2] 🚨 피뢰침 방지 - 급등주 제외 + daily_low = df['low'].min() + daily_change_pct = (daily_high - daily_low) / daily_low * 100 + max_daily_change = get_env_float("MAX_DAILY_CHANGE_PCT", "20.0") # 20% 이상 급등 제외 + if daily_change_pct > max_daily_change: + logger.info(f"🔍 [Pass-피뢰침] {name} {code}: 당일 변동폭 {daily_change_pct:.1f}% 과도 (최대 {max_daily_change}%)") + return None + + # [필터 3] 20선 아래면 스킵 / 20선에서 너무 멀리 올라온 과열 구간도 스킵 + if current_price < ma20: + logger.info(f"🔍 [Pass-MA20] {name} {code}: 현재가({current_price}) < MA20({ma20:.2f})") + return None + # 20선 초과 상한: MA20 대비 N% 이상 위면 고점 매수로 간주 → 스킵 + ma20_cap_pct = get_env_float("MA20_MAX_ABOVE_PCT", "3.0") # 기본 3% 초과 시 스킵 + if ma20 > 0 and current_price > ma20 * (1 + ma20_cap_pct / 100): + gap_pct = (current_price - ma20) / ma20 * 100 + logger.info(f"🔍 [Pass-MA20과열] {name} {code}: 20선 대비 {gap_pct:.1f}% 위 (최대 {ma20_cap_pct}%)") + return None + + # [필터 4] 최소 거래량 필터 (평균의 30%도 안되면 패스) + if current_vol < avg_vol * 0.3: + logger.info(f"🔍 [Pass-Vol] {name} {code}: 거래량 부족 ({current_vol} < {avg_vol * 0.3:.1f})") + return None + + # --------------------------------------------------------- + # [타점 분석] 3분봉 꼬리 잡기 로직 + 캔들 패턴 검증 + candle_open = df['open'].iloc[-1] + candle_close = df['close'].iloc[-1] + candle_high = df['high'].iloc[-1] + candle_low = min(df['low'].iloc[-1], current_price) + total_tail = candle_open - candle_low + + if total_tail <= 0: + return None + + # [캔들 패턴 검증] 망치형(Hammer) 검증 + body = abs(candle_close - candle_open) + upper_shadow = candle_high - max(candle_open, candle_close) + lower_shadow = min(candle_open, candle_close) - candle_low + + # 망치형 조건: 아래꼬리가 몸통의 2배 이상 + 윗꼬리는 매우 짧음 + is_hammer_shape = (lower_shadow >= body * 2) and (upper_shadow <= body * 0.5) + + # 거래량 폭발 여부 (가짜 망치 필터링) + volume_surge_ratio = current_vol / avg_vol if avg_vol > 0 else 1.0 + is_volume_spike = volume_surge_ratio >= 1.5 # 거래량 1.5배 이상 + + # 진짜 망치인지 검증 + if is_hammer_shape: + if is_volume_spike: + logger.info(f"🔨 [진짜 망치] {name} {code}: 거래량 {volume_surge_ratio:.1f}배 폭발 + 망치형 패턴") + else: + logger.info(f"⚠️ [가짜 망치] {name} {code}: 망치형이지만 거래량 부족({volume_surge_ratio:.1f}배) - 스킵") + return None + + # 회복 비율 계산 + recovery_ratio = (current_price - candle_low) / total_tail + + # --- 로그로 상세 수치 확인 --- + volume_multiplier = get_env_float("VOLUME_AVG_MULTIPLIER", "1.0") + log_msg = ( + f"🧐 분석({name} {code}): 가격{current_price} | MA20 {ma20:.1f} | " + f"회복률 {recovery_ratio:.2f} (조건:{self.min_recovery_ratio}~{self.max_recovery_ratio}) | " + f"거래량 {current_vol} (조건:>{avg_vol * volume_multiplier:.1f})" + ) + + # [조건 1] 회복 탄력성 (환경변수 사용: 기본 0.5~0.8) + if not (self.min_recovery_ratio <= recovery_ratio <= self.max_recovery_ratio): + logger.info(f"{log_msg} -> ❌ 회복률 미달/초과") + return None + + # [조건 2] 시가 근접성 (환경변수 사용: 기본 99.5%) + candle_open_buffer = get_env_float("CANDLE_OPEN_PRICE_BUFFER", "0.995") + if current_price < candle_open * candle_open_buffer: + logger.info(f"{log_msg} -> ❌ 시가 회복 부족") + return None + + # [조건 3] 거래량 폭발 여부 (환경변수 사용: 기본 평균 × 1.0) + if current_vol <= avg_vol * volume_multiplier: + logger.info(f"{log_msg} -> ❌ 거래량 파워 부족") + return None + + # --------------------------------------------------------- + # [수급 필터] 최종 관문 + foreigner, institution = self.api.get_intraday_investor(code) + + logger.info(f"✨ 1차 통과({name} {code}): 수급 확인 중... 외인:{foreigner}, 기관:{institution}") + + # 양쪽에서 동시에 대량 매도 중이면 스킵 (환경변수 사용: 기본 -1000) + intraday_threshold = get_env_int("INTRADAY_INVESTOR_NET_BUY_THRESHOLD", "-1000") + if foreigner < intraday_threshold and institution < intraday_threshold: + logger.info(f"⛔ 수급 이탈 감지({name} {code}): 외인{foreigner} / 기관{institution} -> 매수 포기") + return None + + # 5. 매수 시점 대/중/소형 조회 (거래대금 기준, 변동성 구간별 적용) + size_class = None + try: + base_dt = datetime.datetime.now().strftime("%Y%m%d") + res = self.api._safe_request( + self.api.chart.stock_daily_chart_request_ka10081, + code, base_dt, "1", + ) + if res and res.get("stk_dt_pole_chart_qry"): + bars = res["stk_dt_pole_chart_qry"][:10] + values = [abs(float(b.get("trde_prica", 0) or 0)) for b in bars] + avg_trade_value = sum(values) / len(values) if values else 0 + # .env 기준: 대형/중형 최소 거래대금 (원) + large_min = get_env_float("SIZE_CLASS_LARGE_MIN", "5000000000") # 50억 + mid_min = get_env_float("SIZE_CLASS_MID_MIN", "500000000") # 5억 + if avg_trade_value >= large_min: + size_class = "대" + elif avg_trade_value >= mid_min: + size_class = "중" + else: + size_class = "소" + logger.info(f"📊 [{name}] 거래대금 평균 {avg_trade_value/1e8:.1f}억 → {size_class}형") + time.sleep(2) # API 부담 완화 + except Exception as e: + logger.debug(f"대/중/소형 조회 스킵({code}): {e}") + + # 6. 변동성 계산 및 매수 금액 산출 + atr = self.calculate_atr(df) + + # 켈리 비율 가져오기 + kelly_fraction = None + if self.risk_mgr.use_kelly: + kelly_fraction = self.db.calculate_half_kelly() + + # Risk Manager를 통한 기본 안전 매수 금액 계산 (변동성 역가중 + 대/중/소형 반영) + safe_amount = self.risk_mgr.get_position_size( + stock_name=name, + current_balance=self.current_cash, + df=df, + kelly_fraction=kelly_fraction, + size_class=size_class, + ) + + # 종목당 금액 상한(슬롯): 비싼/싼 종목 모두 비슷한 원화 포지션 → 손익률 대비 원화 손익 균등 + use_slot_cap = os.environ.get("USE_SLOT_CAP", "true").lower() == "true" + if use_slot_cap and self.max_stocks > 0: + slot_cap_pct = get_env_float("SLOT_CAP_PCT", "0.9") + slot_money = int(self.current_cash * slot_cap_pct / self.max_stocks) + if safe_amount > slot_money: + logger.info(f"📐 [{name}] 슬롯 상한 적용: {safe_amount:,.0f} → {slot_money:,.0f}원") + safe_amount = slot_money + + # 🔒 금액 손실 한도 기반 추가 캡 (예: 3.5% 손절 + 20만 원 손실 한도) + # - 손절 비율(self.stop_loss_pct)을 기준으로, 손실이 max_loss_per_trade_krw를 넘지 않도록 + # 최대 포지션 금액 = max_loss_per_trade_krw / |stop_loss_pct| 로 역산 + stop_pct_abs = abs(self.stop_loss_pct) if self.stop_loss_pct != 0 else 0.0 + if stop_pct_abs > 0 and self.max_loss_per_trade_krw > 0: + max_position_by_loss = int(self.max_loss_per_trade_krw / stop_pct_abs) + if safe_amount > max_position_by_loss: + logger.info( + f"🛑 [{name}] 금액손실 한도 캡 적용: {safe_amount:,.0f} → " + f"{max_position_by_loss:,.0f}원 (손절 {stop_pct_abs*100:.2f}% 기준, " + f"1트레이드 최대손실 {self.max_loss_per_trade_krw:,.0f}원)" + ) + safe_amount = max_position_by_loss + + if safe_amount < self.risk_mgr.min_amount: + logger.info(f"🚫 [{name}] 계산된 금액이 최소 매수액 미달") + return None + + # 6. 수량 계산 + qty = self.risk_mgr.calculate_quantity(current_price, safe_amount) + + if qty < 1: + return None + + # 손절가/목표가 설정 (ATR 기반) + atr_multiplier_stop = get_env_float("STOP_ATR_MULTIPLIER_TAIL", "3.5") + atr_multiplier_target = get_env_float("TARGET_ATR_MULTIPLIER_TAIL", "8.0") + + stop_price = current_price - (atr * atr_multiplier_stop) + target_price = current_price + (atr * atr_multiplier_target) + + # 최종 매수 시그널 + logger.info(f"🚀 [매수 신호] {name} {code}: 3분봉 꼬리 공략! (회복률: {recovery_ratio:.2f})") + + # --------------------------------------------------------- + # [ML 필터] 승률 예측 (마지막 관문!) + if self.use_ml and self.ml_predictor: + # 피처 추출 + ml_features = { + 'rsi': rsi, + 'volume_ratio': current_vol / avg_vol, + 'tail_length_pct': (total_tail / candle_open) * 100, + 'ma5_gap_pct': ((current_price - df['close'].rolling(5).mean().iloc[-1]) / current_price) * 100, + 'ma20_gap_pct': ((current_price - ma20) / current_price) * 100, + 'foreign_net_buy': foreigner, + 'institution_net_buy': institution, + 'market_hour': datetime.datetime.now().hour + } + + # ML 승률 예측 + win_prob = self.ml_predictor.predict_win_probability(ml_features) + + logger.info(f"🤖 [ML 예측] {name} {code}: 승률 {win_prob:.2%} (임계값: {self.ml_min_prob:.2%})") + + if win_prob < self.ml_min_prob: + logger.info(f"❌ [ML 필터] {name} {code}: 승률 부족 ({win_prob:.2%} < {self.ml_min_prob:.2%}) -> 매수 보류") + return None + else: + logger.info(f"✅ [ML 통과] {name} {code}: 승률 충분 ({win_prob:.2%}) -> 매수 진행!") + + return { + 'code': code, + 'name': name, + 'price': current_price, + 'qty': qty, + 'amount': safe_amount, + 'strategy': 'TAIL_CATCH_3M', + 'stop_price': stop_price, + 'target_price': target_price, + 'atr': atr, + 'size_class': size_class, + } + + except Exception as e: + logger.error(f"⚠️ 매수 시그널 분석 중 에러({name} {code}): {e}", exc_info=True) + return None + + async def _execute_random_split_buy_async(self, code, name, qty, price, signal): + """ + 랜덤 분할 매수 실행 (asyncio 태스크 — 스레드 제거). I/O는 run_in_executor / await sleep. + """ + loop = asyncio.get_event_loop() + try: + split_count = random.randint(10, 20) + base_qty = qty // split_count + remainder = qty % split_count + bought_qty = 0 + logger.info(f"🔫 [진입 시작] {name} 총 {qty}주 | {split_count}회 랜덤 분할 (async)") + for i in range(split_count): + qty_to_buy = base_qty + (remainder if i == split_count - 1 else 0) + if qty_to_buy <= 0: + continue + ok = await loop.run_in_executor( + None, lambda c=code, q=qty_to_buy: self.api.buy_market_order(c, q) + ) + if ok: + bought_qty += qty_to_buy + logger.info(f" ✅ {i+1}/{split_count}: {qty_to_buy}주 체결") + if i < split_count - 1: + await asyncio.sleep(random.uniform(0.8, 1.2)) + else: + logger.error(f" ❌ {i+1}/{split_count}: 매수 실패") + if bought_qty > 0: + now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + trade_data = { + 'code': code, 'name': name, 'avg_buy_price': price, + 'stop_price': signal['stop_price'], 'target_price': signal['target_price'], + 'atr_entry': signal['atr'], 'target_qty': qty, 'current_qty': bought_qty, + 'total_invested': price * bought_qty, 'status': 'HOLDING', + 'strategy': signal['strategy'], 'buy_date': now_str, 'max_price': price, + 'size_class': signal.get('size_class'), + } + self.db.upsert_trade(trade_data) + self.update_account_light(profit_val=0) + total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + msg = f"💰 **[매수 완료]** {name}\n수량: {bought_qty}주 (랜덤 {split_count}회 분할)\n전략: {signal['strategy']}\n자산변동: {total_p:+.2f}%" + logger.info(msg) + self.send_mm(msg) + except Exception as e: + logger.error(f"❌ [{name}] 분할 매수 에러: {e}", exc_info=True) + finally: + async with self.split_buy_lock: + if code in self.active_split_buys: + del self.active_split_buys[code] + logger.debug(f"🧹 [{name}] 분할 매수 태스크 종료 및 정리 완료") + + async def _execute_buy_async(self, signal): + """ + 매수 실행 (async 진입점). 랜덤 분할은 asyncio 태스크로, 나머지는 executor에서 sync 실행. + """ + code = signal['code'] + name = signal['name'] + amount = signal['amount'] + use_random_split = os.environ.get("USE_RANDOM_SPLIT", "true").lower() == "true" + if use_random_split and amount >= 100000: + async with self.split_buy_lock: + if code in self.active_split_buys: + task = self.active_split_buys[code] + if not task.done(): + logger.warning(f"⚠️ [{name}] 이미 분할 매수 진행 중 -> 스킵") + return False + del self.active_split_buys[code] + t = asyncio.create_task(self._execute_random_split_buy_async( + code, name, signal['qty'], signal['price'], signal + )) + self.active_split_buys[code] = t + logger.info(f"🚀 [{name}] 분할 매수 태스크 시작 (메인 루프 계속 진행)") + return True + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: self.execute_buy(signal)) + + def execute_buy(self, signal): + """ + 매수 실행 (TWAP 또는 즉시 매수). 랜덤 분할은 _execute_buy_async에서 asyncio 태스크로 처리. + """ + try: + code = signal['code'] + name = signal['name'] + qty = signal['qty'] + amount = signal['amount'] + price = signal['price'] + active_trades = self.db.get_active_trades() + if code in active_trades: + logger.warning(f"⚠️ [{name}] 이미 보유 중 -> 매수 스킵") + return False + # ========================================================== + # [옵션 1] TWAP 분할 매수 + # ========================================================== + if self.use_twap and self.executor and amount >= 1000000: # 100만원 이상만 분할 + self.executor.add_order(code, name, amount, duration_minutes=30) + logger.info(f"📝 [{name}] TWAP 분할 매수 등록: {amount:,.0f}원") + return True + + # ========================================================== + # [옵션 3] 일반 매수 (즉시 일괄 실행) + # ========================================================== + else: + success = self.api.buy_market_order(code, qty) + + if success: + now_str = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + trade_data = { + 'code': code, + 'name': name, + 'avg_buy_price': price, + 'stop_price': signal['stop_price'], + 'target_price': signal['target_price'], + 'atr_entry': signal['atr'], + 'target_qty': qty, + 'current_qty': qty, + 'total_invested': price * qty, + 'status': 'HOLDING', + 'strategy': signal['strategy'], + 'buy_date': now_str, + 'max_price': price, + 'size_class': signal.get('size_class'), + } + self.db.upsert_trade(trade_data) + + # 매수 후 즉시 예수금 갱신! + self.update_account_light(profit_val=0) + total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + msg = f"💰 **[매수 체결]** {name}\n가격: {price:,.0f}원 × {qty}주\n자산변동: {total_p:+.2f}%" + logger.info(msg) + self.send_mm(msg) + + return True + + return False + + except Exception as e: + logger.error(f"❌ 매수 실행 실패: {e}") + return False + + def check_sell_signals(self): + """ + 보유 종목 매도 시그널 체크 (Dual 로직 완전 이식) + - 어깨 매도 (최우선!) + - 스캘핑 로직 (본절사수/익절보존) + - 2일 보유 전략 + """ + active_trades = self.db.get_active_trades() + + if not active_trades: + return + + for code, trade in list(active_trades.items()): + try: + name = trade['name'] + buy_price = trade['avg_buy_price'] + stop_price = trade['stop_price'] + target_price = trade['target_price'] + qty = trade['current_qty'] + strategy = trade.get('strategy', 'TAIL_CATCH_3M') + buy_date_str = trade.get('buy_date', '') + atr = trade.get('atr_entry', buy_price * 0.01) + + # 현재가 조회 + current_price = self.api.get_current_price(code) + if not current_price: + continue + + # 고점 갱신 + max_price = trade.get('max_price', buy_price) + if current_price > max_price: + max_price = current_price + self.db.update_max_price(code, current_price) + + # DB 현재가 업데이트 + self.db.update_current_price(code, current_price) + + # 손익률 계산 (소수 단위, 예: 0.01 = +1%) + profit_pct = (current_price - buy_price) / buy_price if buy_price > 0 else 0 + profit_val = (current_price - buy_price) * qty + + # POP/LOCK 순수익 계산을 위한 손익 경로 상태 업데이트 + state = self.trade_state.get(code, { + "max_profit_pct": 0.0, + "min_profit_pct": 0.0, + "went_negative": False, + }) + max_profit_pct = max( + state.get("max_profit_pct", 0.0), + (max_price - buy_price) / buy_price if buy_price > 0 else 0, + ) + min_profit_pct = min(state.get("min_profit_pct", 0.0), profit_pct) + went_negative = state.get("went_negative", False) or (profit_pct < 0) + + state["max_profit_pct"] = max_profit_pct + state["min_profit_pct"] = min_profit_pct + state["went_negative"] = went_negative + self.trade_state[code] = state + + # 수수료+세금 왕복 비용을 고려한 순수익률 (대략적인 추정) + net_pct = profit_pct - self.round_trip_cost_pct + max_net_pct = max_profit_pct - self.round_trip_cost_pct + + # 매도 사유 판단 + sell_reason = None + + # 금액 기준 손절 (원 단위) + if profit_val <= -self.max_loss_per_trade_krw: + sell_reason = "금액손실컷" + + # ========================================================== + # [필수 1] 어깨 매도 (Shoulder Cut) - 최우선! + # 고점 대비 3% 이상 빠지면 수익/손실 불문하고 즉시 탈출 + # ========================================================== + drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0 + if drop_from_high >= self.shoulder_cut_pct: + # 어깨 구간에서도 순수익 0.3% 바닥 룰을 한 번 더 체크 + # - lock_net_pct 조건을 만족하면 "순수익0.3보존"으로 기록 + # - 그렇지 않으면 기존대로 어깨매도 + if ( + max_net_pct > self.lock_net_pct + and not went_negative + and net_pct <= self.lock_net_pct + ): + sell_reason = "순수익0.3보존" + else: + sell_reason = f"어깨매도(고점대비-{drop_from_high * 100:.1f}%)" + + # 어깨 매도가 아니면 다른 로직 체크 + if not sell_reason: + # 매수 경과 시간 계산 + hours_passed = 0 + if buy_date_str: + try: + buy_time = datetime.datetime.strptime(buy_date_str, '%Y-%m-%d %H:%M:%S') + hours_passed = (datetime.datetime.now() - buy_time).total_seconds() / 3600 + except: + hours_passed = 0 + + # ========================================================== + # [통합 매도 로직] TAIL/RECOVERED 구분 없이 동일 적용 + # ========================================================== + use_quick_profit = os.environ.get("USE_QUICK_PROFIT_PROTECTION", "true").lower() == "true" + + # [빠른 익절] 작은 수익 보호 (매수 후 30분 이내) + if use_quick_profit and hours_passed < 0.5: + if max_profit_pct >= 0.005 and profit_pct <= 0.0015: + sell_reason = "💨 작은수익보호" + + # [스캘핑 1] 본절사수 + if not sell_reason and (max_price >= buy_price + atr * 1.0) and (current_price <= buy_price + atr * 0.2): + sell_reason = "스캘핑_본절사수" + + # [스캘핑 2] 익절보존 + if not sell_reason and current_price < (max_price - atr * 1.0) and profit_pct > 0: + sell_reason = "스캘핑_익절보존" + + # [순수익 0.3% 바닥 보존] + # - 진입 이후 한 번도 손실 구간을 찍지 않고 (went_negative=False) + # - 순수익이 lock_net_pct(기본 +0.3%)를 한 번이라도 초과했다가 + # - 다시 lock_net_pct 이하로 되돌아오면 강제 익절 + if ( + not sell_reason + and max_net_pct > self.lock_net_pct + and not went_negative + and net_pct <= self.lock_net_pct + ): + sell_reason = "순수익0.3보존" + + # [2일 보유 전략] + if not sell_reason: + if hours_passed < 48: + if profit_pct > 0.05: + sell_reason = "💰 2일내 5%+ 익절" + elif max_price >= buy_price * 1.07 and current_price <= max_price * 0.97: + sell_reason = "📈 2일내 고점7% 찍고 3% 하락" + else: + if profit_pct > 0.02: + sell_reason = "⏰ 2일 경과 2%+ 익절" + elif profit_pct > 0 and current_price < max_price * 0.97: + sell_reason = "⏰ 2일 경과 익절보호" + + # [본절/트레일링] 위 조건에 안 걸렸을 때 + if not sell_reason: + if max_profit_pct >= 0.015 and profit_pct <= 0.005: + sell_reason = "본절보호" + elif profit_pct > 0 and max_profit_pct >= 0.03 and current_price < max_price * 0.99: + sell_reason = "트레일링스탑" + + # ========================================================== + # [공통] 최후의 보루 (목표가 달성 및 손절) + # ========================================================== + if not sell_reason: + if current_price >= target_price: + sell_reason = "목표달성" + elif profit_pct <= self.stop_loss_pct: + sell_reason = f"칼손절({profit_pct * 100:.1f}%)" + elif current_price <= stop_price: + sell_reason = "전략손절" + + # ========================================================== + # [매도 실행] + # ========================================================== + if sell_reason: + if self.api.sell_market_order(code, qty): + env_snapshot = json.dumps(self.get_env_snapshot(), ensure_ascii=False) + self.db.close_trade(code, current_price, sell_reason, env_snapshot=env_snapshot, size_class=trade.get('size_class')) + + # 🚨 손절 시 24시간 재매수 금지 추가! + is_loss = profit_pct < 0 + is_stop_loss = "손절" in sell_reason or "어깨" in sell_reason or "스캘핑" in sell_reason + + if is_loss and is_stop_loss: + self.add_ban(code, name) + logger.warning(f"🚫 [{name}] 손절 발생 → {self.ban_hours}시간 재매수 금지") + + # 매도 후 즉시 예수금 갱신 및 총자산 추정! + self.update_account_light(profit_val) + + # 🚨 리스크 상태 체크 (손실 누적 시 거래 중단!) + self.check_risk_status() + + total_p = ((self.current_total_asset - self.start_day_asset) / self.start_day_asset * 100) if self.start_day_asset > 0 else 0 + + icon = "💰" if profit_pct > 0 else "💧" + r_tag = "[RECOVERED] " if strategy == "RECOVERED" else "" + ban_tag = " 🚫24h금지" if (is_loss and is_stop_loss) else "" + msg = ( + f"{icon} **[매도] {r_tag}{name}**{ban_tag}\n" + f"수익: {profit_pct * 100:.2f}% ({profit_val:,.0f}원)\n" + f"사유: {sell_reason}\n" + f"누적: {total_p:+.2f}%" + ) + logger.info(msg) + self.send_mm(msg) + + except Exception as e: + logger.error(f"❌ [{code}] 매도 체크 실패: {e}") + + def process_twap_orders(self): + """TWAP 분할 매수 처리""" + if not self.use_twap or not self.executor: + return + + # 현재가 정보 수집 + active_orders = self.executor.get_status() + if not active_orders: + return + + current_prices = {} + for code in active_orders.keys(): + price = self.api.get_current_price(code) + if price: + current_prices[code] = price + + # 매수 콜백 함수 + def buy_callback(code, name, amount, price): + qty = int(amount / price) + if qty < 1: + return False + + success = self.api.buy_market_order(code, qty) + + if success: + # DB 업데이트 (분할 매수 누적) + active_trades = self.db.get_active_trades() + + if code in active_trades: + # 기존 보유분 있음 -> 평단가 계산 + existing = active_trades[code] + old_qty = existing['current_qty'] + old_price = existing['avg_buy_price'] + old_invested = existing['total_invested'] + + new_qty = old_qty + qty + new_invested = old_invested + (price * qty) + new_avg_price = new_invested / new_qty + + self.db.upsert_trade({ + 'code': code, + 'name': name, + 'avg_buy_price': new_avg_price, + 'current_qty': new_qty, + 'total_invested': new_invested, + 'status': 'HOLDING', + **existing # 기존 정보 유지 + }) + else: + # 신규 매수 + self.db.upsert_trade({ + 'code': code, + 'name': name, + 'avg_buy_price': price, + 'target_qty': qty, # 임시 + 'current_qty': qty, + 'total_invested': price * qty, + 'status': 'HOLDING', + 'strategy': 'TAIL_CATCH_3M' + }) + + return True + + return False + + # TWAP 처리 + self.executor.process_orders(current_prices, buy_callback) + + def update_universe(self): + """ + 매수 후보군 업데이트 (5분마다) + - 개미털기 우선 (원본 점수 유지) + - 외국인/거래량/상승률/기관 추가 (보너스 점수) + - 강도 4 이상만 필터링 → Top 30 + """ + logger.info(f"🔄 [리스트 갱신] 예수금: {self.current_cash:,.0f}원") + logger.info(f"📡 [복합 스캔] 개미털기 우선 + 4가지 보너스 소스") + + # 매수 가능 금액 계산 + slot_money = int(self.current_cash * 0.9 / self.max_stocks) if self.max_stocks > 0 else 100000 + + all_candidates = {} # {code: {name, price, base_score, bonus_score}} + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 1. 개미털기 (눌림목) - 원본 점수 100% 유지! + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + ant_shaking = self.api.scan_ant_shaking_candidates(max_price_limit=slot_money) + logger.info(f" ✅ [개미털기] {len(ant_shaking)}개 수집 (강도 원본 유지)") + for item in ant_shaking: + code = item['code'] + all_candidates[code] = { + 'code': code, + 'name': item['name'], + 'price': item['price'], + 'base_score': item['score'], # 개미털기 원본 점수 (100%) + 'bonus_score': 0.0, # 보너스 점수 (추가) + 'from_ant': True + } + except Exception as e: + logger.warning(f" ⚠️ [개미털기] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 2. 외국인 순매수 - 보너스 +0.5 (개미털기 있으면 추가) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + foreign_buy = self.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['stk_cd'].strip() + if len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.5 # 순위별 보너스 (최대 +0.5) + + if code in all_candidates: + # 개미털기에 이미 있으면 보너스만 추가 + all_candidates[code]['bonus_score'] += bonus + else: + # 개미털기에 없으면 새로 추가 (기본 점수 3점) + all_candidates[code] = { + 'code': code, + 'name': item.get('stk_nm', '').strip(), + 'price': 0, + 'base_score': 3.0, # 외국인만 있으면 기본 3점 + 'bonus_score': bonus, + 'from_ant': False + } + except Exception as e: + logger.warning(f" ⚠️ [외국인] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 3. 거래량 급증 - 보너스 +0.3 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + volume_surge = self.api.get_volume_surge_stocks(min_volume="50", limit=30) + logger.info(f" ✅ [거래량] {len(volume_surge)}개 수집") + for idx, item in enumerate(volume_surge): + code = item['stk_cd'].strip() + if len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.3 + + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + else: + all_candidates[code] = { + 'code': code, + 'name': item.get('stk_nm', '').strip(), + 'price': float(item.get('prpr', 0)), + 'base_score': 2.5, + 'bonus_score': bonus, + 'from_ant': False + } + except Exception as e: + logger.warning(f" ⚠️ [거래량] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 4. 상승률 상위 - 보너스 +0.2 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + price_movers = self.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['stk_cd'].strip() + if len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.2 + + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + else: + all_candidates[code] = { + 'code': code, + 'name': item.get('stk_nm', '').strip(), + 'price': float(item.get('prpr', 0)), + 'base_score': 2.0, + 'bonus_score': bonus, + 'from_ant': False + } + except Exception as e: + logger.warning(f" ⚠️ [상승률] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 5. 기관 순매수 - 보너스 +0.3 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + try: + inst_buy = self.api.get_institutional_buy_stocks(limit=30) + logger.info(f" ✅ [기관] {len(inst_buy)}개 수집") + for idx, item in enumerate(inst_buy): + code = item['stk_cd'].strip() + if len(code) != 6: + continue + bonus = (30 - idx) / 30.0 * 0.3 + + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + else: + all_candidates[code] = { + 'code': code, + 'name': item.get('stk_nm', '').strip(), + 'price': float(item.get('prpr', 0)), + 'base_score': 2.5, + 'bonus_score': bonus, + 'from_ant': False + } + except Exception as e: + logger.warning(f" ⚠️ [기관] 수집 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 6. 구글 트렌드 괴리율 - 보너스 +0.5 (검색량 급증 vs 주가 미반영) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + if self.trend_analyzer: + # Rate Limit 체크: 429 에러 발생 시 스킵 + if hasattr(self.trend_analyzer, 'rate_limit_hit') and self.trend_analyzer.rate_limit_hit: + logger.warning(" ⚠️ [트렌드 괴리율] Rate Limit 상태 - 이번 스캔 스킵") + else: + try: + # 후보 종목 리스트 준비 (Top 5만 분석 - Rate Limit 방지) + stock_list = [{'code': code, 'name': data['name']} for code, data in all_candidates.items()] + trend_scores = self.trend_analyzer.analyze_stocks(stock_list[:5]) # 최대 5개만 분석 (429 방지) + + logger.info(f" ✅ [트렌드 괴리율] {len(trend_scores)}개 종목 분석 완료") + for code, score in trend_scores.items(): + if score > 0: + bonus = score * 0.5 # 최대 +0.5점 + if code in all_candidates: + all_candidates[code]['bonus_score'] += bonus + logger.info(f" 🔍 [{all_candidates[code]['name']}] 검색량 급증 보너스 +{bonus:.2f}점") + else: + # 트렌드만 있고 다른 조건 없으면 기본 점수 2.0으로 추가 + all_candidates[code] = { + 'code': code, + 'name': next((s['name'] for s in stock_list if s['code'] == code), ''), + 'price': 0, + 'base_score': 2.0, + 'bonus_score': bonus, + 'from_ant': False + } + except Exception as e: + logger.warning(f" ⚠️ [트렌드 괴리율] 분석 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 7. 관세청 수출 호재 종목 추가 - 보너스 +1.0 (즉시 추가) + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + if self.export_sniper: + try: + hot_stocks = self.export_sniper.get_hot_stocks() + if hot_stocks: + logger.info(f" ✅ [수출 호재] {len(hot_stocks)}개 종목 포착") + for stock in hot_stocks: + code = stock['code'] + if code in all_candidates: + all_candidates[code]['bonus_score'] += 1.0 # 호재는 큰 보너스 + logger.info(f" 🔥 [{stock['name']}] 수출 호재 보너스 +1.0점: {stock['reason']}") + else: + # 호재 종목은 즉시 추가 (기본 점수 3.5) + all_candidates[code] = { + 'code': code, + 'name': stock['name'], + 'price': 0, + 'base_score': 3.5, + 'bonus_score': 1.0, + 'from_ant': False + } + logger.info(f" 🚀 [{stock['name']}] 수출 호재 즉시 추가: {stock['reason']}") + except Exception as e: + logger.warning(f" ⚠️ [수출 호재] 감지 실패: {e}") + + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + # 최종 점수 계산 및 필터링 + # ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + final_candidates = [] + ant_count = 0 + bonus_count = 0 + + for code, data in all_candidates.items(): + # 총점 = 기본 점수 + 보너스 + total_score = data['base_score'] + data['bonus_score'] + + # 강도 4 이상만 필터링! + if total_score >= 4.0: + final_candidates.append({ + 'code': code, + 'name': data['name'], + 'score': total_score, + 'price': data['price'], + 'scan_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + + if data['from_ant']: + ant_count += 1 + else: + bonus_count += 1 + + if not final_candidates: + logger.info(" ⚠️ 스캔 결과 없음 (강도 4 이상 종목 없음)") + return + + # 점수 순 정렬 + final_candidates.sort(key=lambda x: x['score'], reverse=True) + + # Top 30만 유지 + final_candidates = final_candidates[:30] + + # DB에 저장 + self.db.update_target_candidates(final_candidates) + + # Top 10 로그 + Mattermost 알림 + logger.info(f"📊 [스캔 완료] 총 {len(final_candidates)}개 종목 (개미털기:{ant_count} + 보너스:{bonus_count})") + top_picks = [f"{x['name']}({x['score']:.1f})" for x in final_candidates[:10]] + logger.info(f" 🔝 Top 10: {', '.join(top_picks)}") + + # MM에 브리핑 + try: + msg = ( + f"📊 **[종목 스캔 완료]**\n" + f"- 개미털기: {ant_count}개 (원본 점수 유지)\n" + f"- 보너스 추가: {bonus_count}개 (외국인/거래량/상승률/기관)\n" + f"- 최종 선정: {len(final_candidates)}개 (강도 4+ 필터)\n" + f"- Top 5: {', '.join(top_picks[:5])}" + ) + self.send_mm(msg) + except Exception as e: + logger.error(f"❌ 스캔 MM 전송 실패: {e}") + + def load_target_universe(self): + """매수 후보군 조회 (DB에서!)""" + return self.db.get_target_candidates() + + async def _news_monitor_loop(self): + """뉴스 감시 루프 (asyncio 태스크 — 스레드 제거). I/O는 await로 대기.""" + if not self.export_sniper: + return + self.news_monitor_running = True + logger.info("📡 [뉴스 감시] 비동기 태스크 시작") + loop = asyncio.get_event_loop() + while self.news_monitor_running: + try: + # 관세청 수출 호재 감지 (동기 I/O → executor에서 실행 후 await) + hot_stocks = await loop.run_in_executor( + None, lambda: self.export_sniper.get_hot_stocks() + ) + if hot_stocks: + candidates = self.db.get_target_candidates() + existing_codes = {c['code'] for c in candidates} + new_stocks = [] + for stock in hot_stocks: + if stock['code'] not in existing_codes: + new_stocks.append({ + 'code': stock['code'], + 'name': stock['name'], + 'score': 4.5, + 'price': 0, + 'scan_time': datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + }) + if new_stocks: + all_candidates = candidates + new_stocks + all_candidates.sort(key=lambda x: x['score'], reverse=True) + all_candidates = all_candidates[:30] + self.db.update_target_candidates(all_candidates) + msg = ( + f"🔥 **[수출 호재 포착]**\n" + f"- {len(new_stocks)}개 종목 즉시 추가:\n" + ) + for stock in new_stocks: + msg += f" • {stock['name']} ({stock['code']})\n" + self.send_mm(msg) + logger.info(f"🔥 [수출 호재] {len(new_stocks)}개 종목 즉시 추가됨") + await asyncio.sleep(60) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ 뉴스 감시 에러: {e}") + await asyncio.sleep(60) + logger.info("📡 [뉴스 감시] 태스크 종료") + + def start_news_monitor(self): + """뉴스 감시 태스크는 _run_async() 내에서 create_task로 시작 (스레드 제거)""" + pass + + @staticmethod + def _seconds_until_next_5min(): + """다음 5분 정각(:00, :05, :10...)까지 남은 초 수. 스캔을 정확히 5분 간격으로 맞추기 위함.""" + now = datetime.datetime.now() + # 현재 분을 5분 단위로 올림 → 다음 5분 정각 + next_min = (now.minute // 5 + 1) * 5 + if next_min >= 60: + next_time = now.replace(minute=0, second=0, microsecond=0) + datetime.timedelta(hours=1) + else: + next_time = now.replace(minute=next_min, second=0, microsecond=0) + return (next_time - now).total_seconds() + + async def _universe_scan_scheduler(self): + """ + 5분마다 정각에 유니버스 스캔 실행. (블로킹 방지: run_in_executor로 실행) + - 기존: 메인 루프에서 update_universe() 호출 시 스캔이 길어져 다음 :05를 놓침 → 10분/15분 간격 발생 + - 변경: 이 태스크만 다음 5분 정각까지 sleep 후 스캔 → 21:50, 21:55, 22:00 ... 고정 간격 + """ + loop = asyncio.get_event_loop() + while True: + try: + if self.is_first_run: + wait_sec = 0 # 첫 실행은 즉시 + else: + wait_sec = max(0, self._seconds_until_next_5min()) + if wait_sec > 0: + await asyncio.sleep(wait_sec) + now = datetime.datetime.now() + logger.info(f"🔄 [스캔 주기] 정각 스캔 시작 | 시각:{now.hour:02d}:{now.minute:02d}:{now.second:02d}") + await loop.run_in_executor(None, self.update_universe) + self.cleanup_banned_list() + self.is_first_run = False + await asyncio.sleep(5) # 스캔 직후 5초 대기 (과부하 방지) + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"❌ [스캔 스케줄러] 에러: {e}") + await asyncio.sleep(60) + + def run(self): + """메인 루프 (진입점). 내부적으로 asyncio.run(_run_async()) 호출.""" + asyncio.run(self._run_async()) + + async def _run_async(self): + """메인 루프 (async). 5분 스캔/뉴스 감시는 별도 태스크, I/O 대기는 await.""" + logger.info("🚀 트레이딩봇 Ver2 가동 시작 (async)") + # 이벤트 루프 내에서 Lock 생성 (asyncio.Lock은 루프 필요) + if self.split_buy_lock is None: + self.split_buy_lock = asyncio.Lock() + + # 🔥 봇 시작 시 계좌 ↔ DB 동기화 (최초 1회) + self.sync_portfolio_from_api() + + # 뉴스 감시 태스크 시작 (스레드 대신 asyncio 태스크) + if self.export_sniper and not self.news_monitor_running: + self._news_monitor_task = asyncio.create_task(self._news_monitor_loop()) + logger.info("✅ 뉴스 감시 태스크 시작 완료") + + # 5분 정각 스캔 스케줄러 태스크 시작 (update_universe는 executor에서 실행 → 메인 루프 블로킹 없음) + self._scan_task = asyncio.create_task(self._universe_scan_scheduler()) + + # 봇 시작 시 장 상태 확인 + first_check = self.api.check_market_status() + if self.force_market_open: + logger.info("⚠️ FORCE_MARKET_OPEN 활성화 - 장 상태 무시(테스트 모드)") + first_check = True + + if not first_check: + self.send_mm("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.") + logger.info("💤 현재 장 운영 시간이 아닙니다. 봇이 대기 모드에 들어갑니다.") + + loop_count = 0 + + while True: + try: + loop_count += 1 + current_time = datetime.datetime.now() + current_hour = current_time.hour + current_minute = current_time.minute + current_second = current_time.second + + # 0. 날짜 변경 체크 + today = current_time.strftime("%Y%m%d") + if today != self.today_date: + logger.info(f"📅 날짜 변경: {self.today_date} → {today}") + self.today_date = today + self.start_day_asset = self.current_total_asset + self.morning_report_sent = False + self.closing_report_sent = False + self.final_report_sent = False + self.news_analyzed_today = False + self.trading_halted = False # 🔓 거래 중단 해제 (새로운 날!) + self._first_sync_done = False # 다음 날 첫 동기화 허용 + # 🧹 금지 종목 정리 (만료된 종목 제거) + self.cleanup_banned_list() + + # ML 모델 재학습 체크 (월요일마다 or 7일 경과 시) + if self.use_ml and self.ml_predictor: + if current_time.weekday() == 0 or self.ml_predictor.should_retrain(): + logger.info("🤖 [ML 재학습] 새로운 주 시작 or 7일 경과 → 모델 재학습 시작") + self.ml_predictor.train_model(retrain=True) + + # 1. 장 운영 시간 체크 + is_open = self.api.check_market_status() + if self.force_market_open: + is_open = True + + # 장 시작/마감 이벤트 처리 + if is_open and not self.was_market_open: + self.refresh_account() + self.check_risk_status() # 🚨 리스크 상태 체크 + self.was_market_open = True + self.send_mm("🌅 **[장 시작]** 봇이 매매를 시작합니다.") + logger.info("🌅 [장 시작] 매매 시작!") + + elif not is_open and self.was_market_open: + self.was_market_open = False + self.refresh_account() + day_profit = self.current_total_asset - self.start_day_asset + self.send_mm(f"🌙 **[장 마감]**\n오늘 손익: {day_profit:,.0f}원") + logger.info(f"🌙 [장 마감] 오늘 손익: {day_profit:,.0f}원") + self.is_first_run = True # 다음 날 스캔 스케줄러용 + self._first_sync_done = False # 다음 날 첫 동기화 허용 + + # 장 휴장 시 대기 + if not is_open: + if loop_count % 60 == 0: # 1분마다 + logger.info("💤 장 운영 시간 외") + await asyncio.sleep(60) + continue + + # [유니버스 갱신] → _universe_scan_scheduler 태스크에서 5분 정각에 실행 (여기서 호출 안 함) + + # 생존 신고 (1분마다) + if current_minute % 1 == 0 and current_second < 2: + active_count = len(self.db.get_active_trades()) + targets = self.load_target_universe() + logger.info(f"👀 [생존] 타겟:{len(targets)} | 보유:{active_count}/{self.max_stocks} | 예수금:{self.current_cash:,.0f}원") + await asyncio.sleep(2) + + # 2. 시간대별 리포트 (리포트 전 계좌 조회 + 동기화!) + if current_hour == 13 and current_minute == 0 and not self.morning_report_sent: + self.refresh_account() + self.sync_portfolio_from_api() # 리포트 전 DB 동기화 + self.send_morning_report() + elif current_hour == 15 and current_minute == 15 and not self.closing_report_sent: + self.refresh_account() + self.sync_portfolio_from_api() + self.send_closing_report() + elif current_hour == 15 and current_minute >= 35 and not self.final_report_sent: + self.refresh_account() + self.sync_portfolio_from_api() + self.send_final_report() + + # 2-1. 뉴스 AI 분석 (하루 1회) + if self.use_news and self.news_analyzer: + if current_hour == self.news_hour and current_minute == 0 and not self.news_analyzed_today: + self.analyze_and_send_news() + + # 3. [제거됨] 계좌 정보 갱신 + # - 시작 시 1회만 조회 + # - 매수/매도 후 update_account_light()로 즉시 갱신 + # - 5분마다 자동 조회는 불필요 (API 낭비) + + # 4. TWAP 분할 매수 처리 + if self.use_twap: + self.process_twap_orders() + + # 5. [동기화 → 주문 정리 → 매도 → 매수] 계좌↔DB 동기화 (첫 1회 + 이후 2분마다) + first_sync_done = getattr(self, "_first_sync_done", False) + if not first_sync_done: + self.sync_portfolio_from_api() + self._first_sync_done = True + logger.info(f"🔄 [첫실행] 동기화 완료") + elif current_minute % 2 == 0 and current_second < 5: + self.sync_portfolio_from_api() + # 미체결 주문 정리 (N초 이상 미체결 주문 취소) + self.cancel_stale_orders(max_age_seconds=10) + # 5분마다 주문·체결 보강 (kt00007, ka10076) — API 2회 + 2초 sleep + if current_minute % 5 == 0 and current_second < 8: + self.sync_order_execution_from_api() + + # 6. 보유 종목 매도 체크 + self.check_sell_signals() + + # 7. 새로운 매수 기회 탐색 + active_count = len(self.db.get_active_trades()) + + if not self.trading_halted and active_count < self.max_stocks: + targets = self.load_target_universe() + + if not targets: + # 타겟이 0개면 매수 체크 로직 안 돌아감 + if loop_count % 60 == 0: # 1분마다 한 번만 + logger.info(f"⚠️ [매수 체크 스킵] 타겟 0개 (DB 저장 실패 or 스캔 결과 없음)") + else: + logger.info(f"🔍 [매수 기회 탐색] 타겟:{len(targets)}개 | 보유:{active_count}/{self.max_stocks}") + for item in targets: + code = item['code'] + name = item['name'] + + # 이미 보유 중이면 스킵 + active_trades = self.db.get_active_trades() + if code in active_trades: + continue + + # 분할 매수 진행 중인 종목 스킵 (asyncio 태스크) + async with self.split_buy_lock: + if code in self.active_split_buys: + task = self.active_split_buys[code] + if not task.done(): + continue # 아직 진행 중이면 스킵 + + # 돈 없으면 스탑 + if self.current_cash < self.risk_mgr.min_amount: + break + + # 매수 시그널 확인 + signal = self.check_buy_signal_tail_catch(code, name) + if signal: + await self._execute_buy_async(signal) + await asyncio.sleep(1) # 매수 후 1초 대기 + + # 7. 대기 (1초마다 체크 - 단타 타이밍 중요!) + await asyncio.sleep(1) + + except KeyboardInterrupt: + logger.info("⏸️ 사용자 중단") + break + + except Exception as e: + import traceback + logger.error(f"❌ 메인 루프 에러: {e}") + logger.error(f"상세 오류:\n{traceback.format_exc()}") + logger.info("⏳ 예외 발생 -> 5초 대기 후 재시도") + await asyncio.sleep(5) + + # 종료 처리 (스캔/뉴스 태스크 취소) + logger.info("🛑 봇 종료 중...") + if getattr(self, "_scan_task", None) and not self._scan_task.done(): + self._scan_task.cancel() + try: + await self._scan_task + except asyncio.CancelledError: + pass + if self._news_monitor_task and not self._news_monitor_task.done(): + self.news_monitor_running = False + self._news_monitor_task.cancel() + try: + await self._news_monitor_task + except asyncio.CancelledError: + pass + if self.db: + self.db.close() + logger.info("✅ 정상 종료 완료") + + +# ========================================================== +# [메인 실행] +# ========================================================== +if __name__ == "__main__": + try: + broker = BrokerAPI() + bot = TradingBotV2(broker) + bot.run() + except Exception as e: + logger.critical(f"💀 봇 실행 실패: {e}") + raise e diff --git a/kiwoom_universe_scanner.py b/kiwoom_universe_scanner.py new file mode 100644 index 0000000..2b35bb7 --- /dev/null +++ b/kiwoom_universe_scanner.py @@ -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() \ No newline at end of file diff --git a/ml_model.pkl b/ml_model.pkl new file mode 100644 index 0000000..fbbf392 Binary files /dev/null and b/ml_model.pkl differ diff --git a/ml_predictor.py b/ml_predictor.py index 4ff41fa..e023f3a 100644 --- a/ml_predictor.py +++ b/ml_predictor.py @@ -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 diff --git a/mm_butler.py b/mm_butler.py new file mode 100644 index 0000000..c5d0e08 --- /dev/null +++ b/mm_butler.py @@ -0,0 +1,1083 @@ +""" +mm_butler.py — 매터모스트 명령 허브 (상주 데몬) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +역할: 매터모스트 채널에서 !명령어를 폴링하여 즉시 실행. + kis_short_ver2 / kis_long_ver2 봇과 완전히 독립적으로 상주. + +지원 명령어 (기본): + !도움말 — 전체 명령어 목록 + !클로드분석 — Claude AI 로 단타봇 거래 분석 + 수치 추천 (소스코드 포함) + !애미분석 — Gemini AI 로 단타봇 거래 분석 + 수치 추천 (소스코드 포함) + !오픈분석 — OpenRouter 경유 모델로 단타봇 거래 분석 + 수치 추천 + !뉴스 — 네이버 금융 뉴스 AI 분석 (기본: Gemini, 위시리스트 관련 필터링 포함) + !클로드뉴스 — Claude 로 뉴스 AI 분석 + !오픈뉴스 — OpenRouter 로 뉴스 AI 분석 + !적용 — 마지막 AI 추천 수치 전체 DB 반영 + !설정 KEY=VALUE — 단일 설정값 DB 반영 + !분석기록 [N] — 저장된 분석 기록 목록/상세 (나중에 '뭐라고 했지' 꺼내보기) + +확장: + 기능이 생길 때마다 _register_commands() 안에 핸들러 함수 하나 추가하면 됩니다. + 다른 파일에서 임포트 후 register_command() 로도 동적 등록 가능합니다. + +실행: + python mm_butler.py # 포그라운드 + 또는 systemd 서비스로 등록 +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import subprocess +import threading +import time +from pathlib import Path +from typing import Callable, Dict, Optional, Tuple + +import requests + +# ------------------------------------------------------------------ +# 내부 모듈 (kis_long_ver1 공용 함수·DB 재사용) +# ------------------------------------------------------------------ +from database import TradeDB, ENV_CONFIG_KEYS +from kis_long_ver1 import ( + get_env_from_db, + get_env_int, + get_env_float, + get_env_bool, + MM_SERVER_URL, + MM_BOT_TOKEN, + MM_CONFIG_FILE, + SCRIPT_DIR, + db as shared_db, +) +from kis_long_ver2 import LongWatchBotV2 +from news_analyzer import NewsAnalyzer + +# ------------------------------------------------------------------ +# Claude 초기화 (클로드분석용) +# ------------------------------------------------------------------ +try: + import anthropic + _CLAUDE_AVAILABLE = True +except ImportError: + _CLAUDE_AVAILABLE = False + +CLAUDE_API_KEY = get_env_from_db("ANTHROPIC_API_KEY", "").strip() +CLAUDE_MODEL_ID = get_env_from_db("CLAUDE_MODEL_ID", "claude-sonnet-4-5").strip() or "claude-sonnet-4-5" +CLAUDE_MAX_TOKENS = get_env_int("CLAUDE_MAX_TOKENS", 8192) + +claude_client: Optional["anthropic.Anthropic"] = None +if _CLAUDE_AVAILABLE and CLAUDE_API_KEY: + try: + claude_client = anthropic.Anthropic(api_key=CLAUDE_API_KEY) + except Exception as _e: + claude_client = None + +# ------------------------------------------------------------------ +# Gemini 초기화 (애미분석용) google.genai 신규 SDK +# ------------------------------------------------------------------ +try: + import google.genai as genai + _GEMINI_AVAILABLE = True +except ImportError: + _GEMINI_AVAILABLE = False + +GEMINI_API_KEY = get_env_from_db("GEMINI_API_KEY", "").strip() +GEMINI_MODEL_ID = get_env_from_db("GEMINI_MODEL_ID", "gemini-2.5-flash").strip() or "gemini-2.5-flash" + +gemini_client = None +if _GEMINI_AVAILABLE and GEMINI_API_KEY: + try: + gemini_client = genai.Client(api_key=GEMINI_API_KEY) + except Exception as _e: + gemini_client = None + +# ------------------------------------------------------------------ +# OpenRouter 초기화 (공용 분석/뉴스용) +# ------------------------------------------------------------------ +OPENROUTER_API_KEY = get_env_from_db("OPENROUTER_API_KEY", "").strip() +OPENROUTER_MODEL_ID = get_env_from_db("OPENROUTER_MODEL_ID", "anthropic/claude-4.5-sonnet").strip() or "anthropic/claude-4.5-sonnet" + +# ------------------------------------------------------------------ +# 로깅 +# ------------------------------------------------------------------ +logging.basicConfig( + format="[%(asctime)s][%(name)s] %(message)s", + datefmt="%H:%M:%S", + level=logging.INFO, +) +logger = logging.getLogger("MMButler") + + +# ================================================================== +# 유틸리티 함수 +# ================================================================== + +def _get_env_numeric_snapshot(db: TradeDB) -> str: + """DB 최신 env에서 계좌/키/토큰/URL 제외한 수치·설정만 반환 (KEY=값 줄 단위).""" + EXCLUDE = { + "MM_SERVER_URL", "MM_BOT_TOKEN_", "MATTERMOST_CHANNEL", + "GEMINI_API_KEY", "GEMINI_MODEL_ID", + "ANTHROPIC_API_KEY", "CLAUDE_API_KEY", "CLAUDE_MODEL_ID", + "OPENROUTER_API_KEY", "OPENROUTER_MODEL_ID", + "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", "MM_BUTLER_CHANNEL", + } + latest = db.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_journalctl_recent(lines: int = 500, unit: Optional[str] = None) -> str: + """journalctl 최근 N줄. unit 있으면 -u unit 적용.""" + 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: + logger.warning("journalctl 조회 실패: %s", e) + return "" + + +def _save_ai_recommendations(db: TradeDB, analysis_text: str) -> None: + """AI 분석문에서 'KEY=값' 추천 줄만 추출해 DB에 저장 (!적용 시 사용).""" + if not analysis_text: + return + valid_keys = set(ENV_CONFIG_KEYS) + lines = [] + 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: + lines.append(f"{m.group(1)}={m.group(2).strip()}") + if lines: + db.set_last_ai_recommendations("\n".join(lines)) + + +def _read_bot_source() -> str: + """ + kis_short_ver2.py 소스 전체를 읽어 반환. + AI가 매매 로직을 이해하고 정확한 변수명·수치로 추천하기 위해 프롬프트에 첨부. + AI_SOURCE_MAX_CHARS(env/DB) 로 최대 길이 제한 (기본 120,000자 ≈ Claude 30k 토큰). + """ + src_path = SCRIPT_DIR / "kis_short_ver2.py" + if not src_path.exists(): + return "(kis_short_ver2.py 파일 없음)" + try: + max_chars = get_env_int("AI_SOURCE_MAX_CHARS", 120000) + content = src_path.read_text(encoding="utf-8") + if len(content) > max_chars: + content = content[:max_chars] + f"\n\n...(이하 {len(content) - max_chars:,}자 생략)..." + return content + except Exception as e: + logger.warning("kis_short_ver2.py 읽기 실패: %s", e) + return f"(소스 읽기 실패: {e})" + + +def _build_analyze_context(db: TradeDB) -> Dict: + """ + 분석에 필요한 공통 컨텍스트 수집: + env 수치, journalctl 로그, 최근 거래 내역, 유니버스 후보 수, 소스코드. + 두 AI 핸들러가 이 함수를 공유하여 중복 코드 제거. + """ + env_lines = _get_env_numeric_snapshot(db) + + log_lines_cnt = get_env_int("AI_JOURNAL_LINES", 500) + journal_unit = os.environ.get("JOURNALCTL_UNIT", "").strip() or None + journal_log = _get_journalctl_recent(lines=log_lines_cnt, unit=journal_unit) or "(journalctl 로그 없음)" + + recent_trades = [] + try: + cursor = db.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 10 + """ + ) + for row in cursor.fetchall(): + recent_trades.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: + logger.error("거래 내역 조회 실패: %s", e) + + try: + row = db.conn.execute( + "SELECT COUNT(*) FROM target_candidates WHERE scan_date = date('now')" + ).fetchone() + candidate_count = row[0] if row else 0 + except Exception: + candidate_count = 0 + + bot_source = _read_bot_source() + + return { + "env_lines": env_lines, + "journal_log": journal_log, + "log_lines_cnt": log_lines_cnt, + "recent_trades": recent_trades, + "candidate_count": candidate_count, + "bot_source": bot_source, + } + + +def _build_analyze_prompt(ctx: Dict) -> Tuple[str, str]: + """ + 컨텍스트로부터 AI 프롬프트 문자열 생성. + kis_short_ver2.py 소스코드를 포함하여 AI가 변수명·로직을 정확히 파악하도록 함. + Returns: (prompt 문자열, summary 문자열) + """ + env_lines = ctx["env_lines"] + journal_log = ctx["journal_log"] + log_lines_cnt = ctx["log_lines_cnt"] + recent_trades = ctx["recent_trades"] + candidate_count = ctx["candidate_count"] + bot_source = ctx["bot_source"] + + source_section = f""" +**단타봇 전체 소스코드 (kis_short_ver2.py) — 로직·변수명 참고용** +```python +{bot_source} +``` +""" + + if not recent_trades: + summary = f"- 유니버스 후보: {candidate_count}개\n- 최근 거래: 없음" + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. +아래 단타봇 소스코드를 읽고 로직을 완전히 이해한 뒤, 설정 수치와 로그를 바탕으로 분석해 주세요. +{source_section} + +**현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: 없음 + +**현재 DB 설정 수치 (계좌/키 제외)** +``` +{env_lines} +``` + +**봇 최근 로그 (journalctl 최근 {log_lines_cnt}줄)** +``` +{journal_log[:12000]} +``` + +**당신의 임무** +1. 소스코드의 매수 조건·필터 로직을 파악하고, 현재 설정이 너무 엄격하거나 느슨한 부분을 찾아 문제점 분석. +2. **추천**: 반드시 KEY=값 한 줄에 하나. 이유·주석 금지. 그대로 DB 복붙 가능해야 함. + 변수명은 반드시 소스코드에 실제로 존재하는 것만 사용할 것. +3. 예상 효과 한두 줄. + +**출력 형식 (반드시 준수)** +## 🔍 문제점 +1. [소스 로직 기반 구체적 문제 1] +2. [소스 로직 기반 구체적 문제 2] + +## 💡 수치 추천 (KEY=값, 한 줄에 하나) +KEY=값 +(필요한 것만) + +## 📈 예상 효과 +- [효과] +""" + else: + total = len(recent_trades) + wins = sum(1 for t in recent_trades if t["profit_rate"] > 0) + win_rate = wins / total * 100 if total else 0 + avg_profit = sum(t["profit_rate"] for t in recent_trades) / total + total_pnl = sum(t["realized_pnl"] for t in recent_trades) + avg_hold = sum(t["hold_minutes"] for t in recent_trades) / total + + trades_text = "" + for i, t in enumerate(recent_trades, 1): + trades_text += ( + f"\n[거래 {i}] {t['name']} ({t['strategy']})\n" + f"- 매수: {t['buy_price']:,.0f}원 × {t['qty']}주 | 매도: {t['sell_price']:,.0f}원\n" + f"- 손익: {t['profit_rate']:+.2f}% ({t['realized_pnl']:,.0f}원) | 보유: {t['hold_minutes']}분\n" + f"- 사유: {t['sell_reason']}\n" + ) + + summary = ( + f"- 유니버스 후보: {candidate_count}개\n" + f"- 최근 거래: {total}건 | 승률: {win_rate:.1f}%\n" + f"- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:+,.0f}원" + ) + + prompt = f"""당신은 퀀트 트레이딩 전문가입니다. +아래 단타봇 소스코드를 읽고 로직을 완전히 이해한 뒤, 거래 내역·설정 수치를 분석해 주세요. +{source_section} + +**현재 상태** +- 유니버스 후보: {candidate_count}개 +- 최근 거래: {total}건 | 승률: {win_rate:.1f}% ({wins}승 {total - wins}패) +- 평균 수익률: {avg_profit:.2f}% | 총 손익: {total_pnl:+,.0f}원 | 평균 보유: {avg_hold:.0f}분 + +**최근 거래 내역** +{trades_text} + +**현재 DB 설정 수치 (계좌/키 제외)** +``` +{env_lines} +``` + +**봇 최근 로그 (journalctl 최근 {log_lines_cnt}줄)** +``` +{journal_log[:12000]} +``` + +**당신의 임무** +1. 소스코드의 매수·매도 로직과 거래 내역을 대조해 승률 하락 원인을 3가지 구체적으로 진단. + (예: 어느 함수의 어떤 조건이 문제인지 명시) +2. **추천**: KEY=값 한 줄에 하나. 이유·주석 금지. DB 복붙 가능하게. + 변수명은 반드시 소스코드에 실제로 존재하는 것만 사용할 것. +3. 예상 효과 한두 줄. + +**출력 형식 (반드시 준수)** +## 🔍 문제점 (승률 하락 원인) +1. [소스 로직 기반 구체적 문제 1] +2. [소스 로직 기반 구체적 문제 2] +3. [소스 로직 기반 구체적 문제 3] + +## 💡 수치 추천 (KEY=값, 한 줄에 하나) +KEY=값 +(필요한 것만) + +## 📈 예상 효과 +- [효과] +""" + + return prompt, summary + + +def _call_claude(prompt: str) -> str: + """Claude API 호출 후 텍스트 반환. 실패 시 오류 문자열 반환.""" + if not claude_client: + return "❌ Claude API 미설정 (ANTHROPIC_API_KEY 확인)" + try: + max_tok = get_env_int("CLAUDE_MAX_TOKENS", 8192) + model = get_env_from_db("CLAUDE_MODEL_ID", CLAUDE_MODEL_ID).strip() or CLAUDE_MODEL_ID + response = claude_client.messages.create( + model=model, + max_tokens=max_tok, + messages=[{"role": "user", "content": prompt}], + ) + return response.content[0].text if response.content else "(응답 없음)" + except Exception as e: + logger.error("Claude 호출 실패: %s", e) + return f"❌ Claude 호출 실패: {e}" + + +def _call_gemini(prompt: str) -> str: + """Gemini API 호출 후 텍스트 반환. 실패 시 오류 문자열 반환.""" + if not gemini_client: + return "❌ Gemini API 미설정 (GEMINI_API_KEY 확인)" + try: + model = get_env_from_db("GEMINI_MODEL_ID", GEMINI_MODEL_ID).strip() or GEMINI_MODEL_ID + response = gemini_client.models.generate_content(model=model, contents=prompt) + return ( + getattr(response, "text", None) + or (response.candidates[0].content.parts[0].text if response.candidates else "(응답 없음)") + ) + except Exception as e: + logger.error("Gemini 호출 실패: %s", e) + return f"❌ Gemini 호출 실패: {e}" + + +def _call_openrouter(prompt: str, model: Optional[str] = None) -> str: + """OpenRouter API 호출 후 텍스트 반환. 실패 시 오류 문자열 반환.""" + if not OPENROUTER_API_KEY: + return "❌ OpenRouter API 미설정 (OPENROUTER_API_KEY 확인)" + use_model = (model or get_env_from_db("OPENROUTER_MODEL_ID", OPENROUTER_MODEL_ID)).strip() or OPENROUTER_MODEL_ID + try: + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + } + payload = { + "model": use_model, + "messages": [{"role": "user", "content": prompt}], + } + r = requests.post("https://openrouter.ai/api/v1/chat/completions", headers=headers, json=payload, timeout=60) + r.raise_for_status() + data = r.json() + choice = (data.get("choices") or [{}])[0] + message = choice.get("message", {}) + content = message.get("content") + if isinstance(content, list): + # 일부 OpenAI 호환 구현은 content를 조각 리스트로 반환 + text = "".join( + (part.get("text", "") if isinstance(part, dict) else str(part)) + for part in content + ) + else: + text = content or "" + return text or "(응답 없음)" + except Exception as e: + logger.error("OpenRouter 호출 실패: %s", e) + return f"❌ OpenRouter 호출 실패: {e}" + + +# ================================================================== +# 핸들러 함수들 (각 !명령어에 대응, CommandHub 에 등록) +# ================================================================== + +def handler_help(args: str, db: TradeDB, hub: "CommandHub") -> str: + """!도움말 — 등록된 모든 명령어와 설명 출력.""" + lines = ["**🤖 MM Butler 명령어 목록**", ""] + for cmd, (_, desc) in sorted(hub.registry.items()): + lines.append(f"- `!{cmd}` — {desc}") + lines.append("") + lines.append("_채널에서 위 명령어를 입력하면 즉시 실행됩니다._") + return "\n".join(lines) + + +def handler_claude_analyze(args: str, db: TradeDB, hub: "CommandHub") -> str: + """ + !클로드분석 — Claude AI 로 단타봇 거래 분석 + 수치 추천. + kis_short_ver2.py 전체 소스를 프롬프트에 포함하여 로직 기반의 정밀 분석. + """ + if not claude_client: + return "❌ Claude API 키가 설정되어 있지 않습니다. `ANTHROPIC_API_KEY` 를 DB에 추가해 주세요." + + try: + ctx = _build_analyze_context(db) + except Exception as e: + return f"❌ 컨텍스트 수집 실패: {e}" + + prompt, summary = _build_analyze_prompt(ctx) + analysis = _call_claude(prompt) + _save_ai_recommendations(db, analysis) + db.insert_ai_analysis_log("claude", summary, analysis) + + return ( + "🤖 **[클로드 분석]**\n\n" + f"📊 **현재 상태**\n{summary}\n\n" + f"{analysis}\n\n" + "---\n_추천 수치는 `!적용` 으로 DB에 즉시 반영할 수 있습니다._" + ) + + +def handler_gemini_analyze(args: str, db: TradeDB, hub: "CommandHub") -> str: + """ + !애미분석 — Gemini AI 로 단타봇 거래 분석 + 수치 추천. + kis_short_ver2.py 전체 소스를 프롬프트에 포함하여 로직 기반의 정밀 분석. + Gemini 2.5 Flash는 100만 토큰 컨텍스트로 긴 소스도 부담 없이 처리. + """ + if not gemini_client: + return "❌ Gemini API 키가 설정되어 있지 않습니다. `GEMINI_API_KEY` 를 DB에 추가해 주세요." + + try: + ctx = _build_analyze_context(db) + except Exception as e: + return f"❌ 컨텍스트 수집 실패: {e}" + + prompt, summary = _build_analyze_prompt(ctx) + analysis = _call_gemini(prompt) + _save_ai_recommendations(db, analysis) + db.insert_ai_analysis_log("gemini", summary, analysis) + + return ( + "🤖 **[애미(Gemini) 분석]**\n\n" + f"📊 **현재 상태**\n{summary}\n\n" + f"{analysis}\n\n" + "---\n_추천 수치는 `!적용` 으로 DB에 즉시 반영할 수 있습니다._" + ) + + +def handler_openrouter_analyze(args: str, db: TradeDB, hub: "CommandHub") -> str: + """ + !오픈분석 — OpenRouter 경유 모델로 단타봇 거래 분석 + 수치 추천. + kis_short_ver2.py 전체 소스를 프롬프트에 포함하여 로직 기반의 정밀 분석. + 기본 모델은 env/DB의 OPENROUTER_MODEL_ID (기본값: anthropic/claude-3.5-sonnet). + """ + if not OPENROUTER_API_KEY: + return "❌ OpenRouter API 키가 설정되어 있지 않습니다. `OPENROUTER_API_KEY` 를 DB에 추가해 주세요." + + try: + ctx = _build_analyze_context(db) + except Exception as e: + return f"❌ 컨텍스트 수집 실패: {e}" + + prompt, summary = _build_analyze_prompt(ctx) + analysis = _call_openrouter(prompt) + _save_ai_recommendations(db, analysis) + db.insert_ai_analysis_log("openrouter", summary, analysis) + + return ( + "🤖 **[오픈(OpenRouter) 분석]**\n\n" + f"📊 **현재 상태**\n{summary}\n\n" + f"{analysis}\n\n" + "---\n_추천 수치는 `!적용` 으로 DB에 즉시 반영할 수 있습니다._" + ) + + +def _analyze_news_with_backend(news_list: list, backend: str) -> Optional[Dict]: + """ + 공통 뉴스 리스트를 받아 backend(gemini/claude/openrouter)로 분석 요청. + JSON 파싱까지 수행하여 dict 반환. + """ + if not news_list: + return None + + 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만 출력하세요. +""" + + if backend == "gemini": + raw = _call_gemini(prompt) + elif backend == "claude": + raw = _call_claude(prompt) + elif backend == "openrouter": + raw = _call_openrouter(prompt) + else: + return None + + if not raw or raw.startswith("❌"): + return None + + import json as _json + + text = raw.strip() + if text.startswith("```"): + parts = text.split("```") + if len(parts) >= 2: + text = parts[1] + if text.lstrip().startswith("json"): + text = text.lstrip()[4:] + text = text.strip() + try: + result = _json.loads(text) + return result if isinstance(result, dict) else None + except Exception as e: + logger.error("뉴스 JSON 파싱 실패(%s): %r", backend, e) + return None + + +def _run_news_with_backend(db: TradeDB, backend: str) -> str: + """뉴스 크롤링 + 지정 백엔드로 분석 + 위시리스트 필터링까지 공통 처리.""" + analyzer = NewsAnalyzer() + try: + max_news = get_env_int("NEWS_MAX_COUNT", 5) + news_list = analyzer.crawl_naver_finance_news(max_news=max_news) + if not news_list: + return "📰 크롤링된 뉴스가 없습니다." + + analysis = _analyze_news_with_backend(news_list, backend) + if not analysis: + return "📰 뉴스 AI 분석 결과가 없습니다." + + # 위시리스트 관련 종목 필터링 + watch_map: Dict[str, str] = {} + try: + watchlist_path = SCRIPT_DIR / "long_term_watchlist.json" + if watchlist_path.exists(): + with open(watchlist_path, "r", encoding="utf-8") as f: + obj = json.load(f) + for it in (obj.get("items", []) if isinstance(obj, dict) else []): + code = (it.get("code") or "").strip() + if code: + watch_map[code] = it.get("name", code) + active = db.get_active_trades(strategy_prefix="LONG") + for code, trade in active.items(): + watch_map.setdefault(code, trade.get("name", code)) + except Exception as e: + logger.debug("위시리스트 로드 실패(무시): %s", e) + + related = [] + for stock in analysis.get("recommended_stocks", []): + code = (stock.get("code") or "").strip() + if code and code in watch_map: + related.append(f"- `{code}` {watch_map[code]}: {stock.get('reason', '')}") + + mm_msg = analyzer.format_analysis_for_mattermost(analysis, news_list) or "" + if not mm_msg: + return "📰 포맷된 뉴스 메시지가 없습니다." + + if related: + mm_msg += "\n\n**🎯 위시리스트 관련 종목**\n" + "\n".join(related) + else: + mm_msg += "\n\n_위시리스트와 직접 매칭된 종목 없음 (전체 시장 참고용)_" + + return mm_msg + + except Exception as e: + logger.error("뉴스 핸들러 실패(%s): %s", backend, e) + return f"❌ 뉴스 분석 실패: {e}" + + +def handler_news(args: str, db: TradeDB, hub: "CommandHub") -> str: + """ + !뉴스 — 네이버 금융 뉴스 크롤링 → Gemini AI 분석 → 위시리스트 관련 필터링. + (기본 백엔드: Gemini) + """ + if not gemini_client: + return "❌ 뉴스 AI 분석 불가 (Gemini API 키 미설정)" + return _run_news_with_backend(db, backend="gemini") + + +def handler_news_claude(args: str, db: TradeDB, hub: "CommandHub") -> str: + """!클로드뉴스 — 뉴스 크롤링 + Claude 분석.""" + if not claude_client: + return "❌ 뉴스 AI 분석 불가 (ANTHROPIC_API_KEY 미설정)" + return _run_news_with_backend(db, backend="claude") + + +def handler_news_openrouter(args: str, db: TradeDB, hub: "CommandHub") -> str: + """!오픈뉴스 — 뉴스 크롤링 + OpenRouter 분석.""" + if not OPENROUTER_API_KEY: + return "❌ 뉴스 AI 분석 불가 (OPENROUTER_API_KEY 미설정)" + return _run_news_with_backend(db, backend="openrouter") + + +def handler_watchlist_analyze(args: str, db: TradeDB, hub: "CommandHub") -> str: + """ + !종목분석 — kis_long_ver2 장기 투자 체크 리포트를 즉시 1회 실행. + - 장 시작/마감에 자동으로 나가는 리포트와 동일한 형식을, 사용자가 원할 때 수동으로 호출. + - 실제 리포트 전송은 kis_long_ver2.LongWatchBotV2 의 Mattermost 설정(MM_CHANNEL_LONG 등)을 그대로 사용. + """ + try: + bot = LongWatchBotV2() + # 장 시작/마감과 동일 포맷, 레이블만 '수동'으로 구분 + bot.send_long_check_report("수동 요청") + return "📊 장기 투자 체크 리포트를 즉시 전송했습니다. (kis_long_ver2 형식 그대로)" + except Exception as e: + logger.error("종목분석(장기 리포트) 핸들러 실패: %s", e) + return f"❌ 종목분석(장기 리포트) 실행 실패: {e}" + + +def handler_analysis_log(args: str, db: TradeDB, hub: "CommandHub") -> str: + """ + !분석기록 [N] — 저장된 AI 분석 기록 조회. + 인수 없음: 최근 5건 목록 (id, 시각, 모델, 요약). + !분석기록 3 — id=3 건의 전체 응답 보기 (나중에 '뭐라고 했지' 꺼내볼 때). + """ + arg = (args or "").strip() + if arg.isdigit(): + log_id = int(arg) + row = db.get_ai_analysis_log_by_id(log_id) + if not row: + return f"❌ id={log_id} 분석 기록이 없습니다." + return ( + f"📋 **분석기록 id={row['id']}** ({row['created_at']} | {row['model']})\n\n" + f"**당시 상태**\n{row['context_summary'] or '(없음)'}\n\n" + f"**AI 응답**\n{row['response'] or '(없음)'}\n\n" + "---\n_목록: `!분석기록`_" + ) + # 목록 (최근 5건) + items = db.get_ai_analysis_log_list(limit=5) + if not items: + return "저장된 분석 기록이 없습니다. `!클로드분석` 또는 `!애미분석` 을 먼저 실행해 보세요." + lines = ["📋 **최근 분석기록 (최근 5건)**", ""] + for it in items: + ctx = (it["context_summary"] or "")[:120] + if len(it["context_summary"] or "") > 120: + ctx += "…" + prev = (it["response_preview"] or "")[:150] + if len(it["response_preview"] or "") > 150: + prev += "…" + lines.append(f"**id={it['id']}** {it['created_at']} | {it['model']}\n 상태: {ctx}\n 응답: {prev}") + lines.append("") + lines.append("_전체 보기: `!분석기록 3` (3을 원하는 id로 변경)_") + return "\n".join(lines) + + +def handler_apply(args: str, db: TradeDB, hub: "CommandHub") -> str: + """!적용 — 마지막 AI 추천 수치 전체 DB 반영.""" + text = db.get_last_ai_recommendations() + if not text or not text.strip(): + return "저장된 AI 추천이 없습니다. 먼저 `!클로드분석` 또는 `!애미분석` 을 실행해 주세요." + + valid_keys = set(ENV_CONFIG_KEYS) + updates: Dict[str, str] = {} + 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 "❌ 추천문에서 유효한 KEY=값을 찾지 못했습니다." + + latest = db.get_latest_env() + if not latest or not latest.get("snapshot"): + return "❌ 현재 env가 없습니다." + + snap = dict(latest["snapshot"]) + old_snap = dict(snap) + snap.update(updates) + rid = db.insert_env_snapshot(snap) + if rid is None: + return "❌ DB 반영 실패." + + # 변경 전→후를 항목별로 나열하여 가독성 향상 + lines = [f"⚙️ **AI 추천 일괄 적용 완료** ({len(updates)}건)\n"] + for k in sorted(updates.keys()): + old_v = old_snap.get(k) + new_v = updates[k] + old_str = f"`{old_v}`" if old_v not in (None, "") else "_없음_" + lines.append(f"- `{k}` : {old_str} → **`{new_v}`**") + lines.append("\n_봇 재시작 없이 다음 루프에서 자동 반영됩니다._") + return "\n".join(lines) + + +def handler_set(args: str, db: TradeDB, hub: "CommandHub") -> str: + """!설정 KEY=값 또는 !설정 KEY 값 — 단일 설정값 DB 반영.""" + rest = args.strip() + if not rest: + return "사용법: `!설정 KEY=값` 또는 `!설정 KEY 값`" + + 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] + + key = key.strip().upper() + if key not in set(ENV_CONFIG_KEYS): + similar = [k for k in ENV_CONFIG_KEYS if key in k or k.startswith(key[:4])] + hint = f"\n혹시 이 키를 찾으시나요? {', '.join(f'`{k}`' for k in similar[:5])}" if similar else "" + return f"❌ 알 수 없는 설정 키: `{key}`{hint}" + + latest = db.get_latest_env() + if not latest or not latest.get("snapshot"): + return "❌ 현재 env가 없습니다." + + snap = dict(latest["snapshot"]) + old_val = snap.get(key) + snap[key] = val.strip() + rid = db.insert_env_snapshot(snap) + if rid is None: + return "❌ DB 반영 실패." + + # 이전값 → 새값 명시, 관련 경고 추가 + old_str = f"`{old_val}`" if old_val not in (None, "") else "_없음_" + warn = "" + # STOP_LOSS_PCT 양수 입력 시 경고 (자동 부호반전 안내) + if key == "STOP_LOSS_PCT": + try: + fval = float(val) + if fval > 0: + warn = "\n⚠️ 양수 값입니다. 손절은 음수여야 합니다 (예: `-0.02`). 봇 코드에서 자동 반전되지만 DB에는 음수로 저장하세요." + except ValueError: + pass + # MAX_LOSS / STOP_LOSS 정합 힌트 + if key in ("STOP_LOSS_PCT", "MAX_LOSS_PER_TRADE_KRW"): + try: + stop_pct = abs(float(snap.get("STOP_LOSS_PCT") or 0)) + max_loss = float(snap.get("MAX_LOSS_PER_TRADE_KRW") or 0) + if stop_pct > 0 and max_loss > 0: + implied_pos = max_loss / stop_pct + warn += f"\n📐 포지션 상한 자동계산: MAX_LOSS({max_loss:,.0f}원) ÷ |STOP_LOSS({stop_pct:.4f})| = **{implied_pos:,.0f}원**" + except (ValueError, ZeroDivisionError): + pass + + return ( + f"⚙️ **설정 반영 완료**\n" + f"`{key}` : {old_str} → **`{val}`**" + f"{warn}\n" + f"_봇 재시작 없이 다음 루프에서 자동 반영됩니다._" + ) + + +# ================================================================== +# CommandHub — MM 폴링 + 명령어 라우터 +# ================================================================== + +class CommandHub: + """ + 매터모스트 채널을 폴링해서 !명령어를 감지하고 등록된 핸들러를 실행. + + 핸들러 시그니처: + def handler(args: str, db: TradeDB, hub: CommandHub) -> str: + ... # 반환값이 MM 응답 메시지 + """ + + KV_LAST_SEEN_TS = "mm_butler_last_seen_ts" + + def __init__( + self, + server_url: str, + bot_token: str, + channel_alias: str, + db: TradeDB, + poll_interval_sec: int = 15, + ): + self.server_url = server_url.rstrip("/") + self.bot_token = bot_token + self.channel_alias = channel_alias + 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 + + # { cmd_name: (handler_fn, description) } + self.registry: Dict[str, Tuple[Callable, str]] = {} + self._register_commands() + + # ------------------------------------------------------------------ + # 명령어 등록 + # ------------------------------------------------------------------ + def _register_commands(self) -> None: + """기본 명령어 등록. 새 기능은 이 안에 한 줄 추가.""" + self.register_command("도움말", handler_help, "전체 명령어 목록 출력") + self.register_command("클로드분석", handler_claude_analyze, "Claude AI 로 소스코드 기반 거래 분석 + 수치 추천") + self.register_command("애미분석", handler_gemini_analyze, "Gemini AI 로 소스코드 기반 거래 분석 + 수치 추천") + self.register_command("오픈분석", handler_openrouter_analyze, "OpenRouter 로 소스코드 기반 거래 분석 + 수치 추천") + self.register_command("종목분석", handler_watchlist_analyze, "long_term_watchlist 기반 장기 위시리스트 Gemini 분석") + self.register_command("애미뉴스", handler_news, "네이버 금융 뉴스 AI 분석 (기본: Gemini, 위시리스트 필터링 포함)") + self.register_command("클로드뉴스", handler_news_claude, "네이버 금융 뉴스 AI 분석 (Claude)") + self.register_command("오픈뉴스", handler_news_openrouter, "네이버 금융 뉴스 AI 분석 (OpenRouter)") + self.register_command("적용", handler_apply, "마지막 AI 추천 수치 전체 DB 반영") + self.register_command("설정", handler_set, "단일 설정값 DB 반영 (예: !설정 MAX_STOCKS=4)") + self.register_command("분석기록", handler_analysis_log, "저장된 AI 분석 기록 목록/상세 (나중에 꺼내보기)") + + def register_command(self, cmd: str, handler: Callable, description: str = "") -> None: + """외부에서 동적으로 명령어 추가 가능.""" + self.registry[cmd.strip()] = (handler, description) + logger.debug("명령어 등록: !%s", cmd) + + # ------------------------------------------------------------------ + # MM API 헬퍼 + # ------------------------------------------------------------------ + def _load_channel_id(self) -> Optional[str]: + if self._channel_id: + return self._channel_id + try: + if MM_CONFIG_FILE.exists(): + with open(MM_CONFIG_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + self._channel_id = data.get("channels", {}).get(self.channel_alias) + except Exception as e: + logger.warning("채널 ID 로드 실패: %s", e) + return self._channel_id + + 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") + except Exception as e: + logger.warning("봇 user_id 조회 실패: %s", e) + return self._bot_user_id + + 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 p and not (bot_id and p.get("user_id") == bot_id): + 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=10, + ) + r.raise_for_status() + return True + except Exception as e: + logger.error("MM 전송 실패: %s", e) + return False + + # ------------------------------------------------------------------ + # 명령어 처리 + # ------------------------------------------------------------------ + def _dispatch(self, message: str, post_id: str) -> Optional[str]: + """!로 시작하는 메시지를 파싱해 핸들러 호출. 반환값 = 응답 메시지.""" + msg = (message or "").strip() + if not msg.startswith("!"): + return None + + # "!명령어 나머지인수" 분리 + parts = msg[1:].split(None, 1) + cmd = parts[0].strip() + args = parts[1] if len(parts) > 1 else "" + + entry = self.registry.get(cmd) + if not entry: + return None # 모르는 명령어는 무시 (다른 봇 명령 포함) + + handler_fn, _ = entry + try: + logger.info("명령 실행: !%s (args=%r)", cmd, args[:50]) + return handler_fn(args, self.db, self) + except Exception as e: + logger.error("핸들러 !%s 실패: %s", cmd, e) + return f"❌ `!{cmd}` 실행 중 오류: {e}" + + # ------------------------------------------------------------------ + # 폴링 루프 + # ------------------------------------------------------------------ + def _poll_loop(self) -> None: + 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_text = (p.get("message") or "").strip() + reply = self._dispatch(msg_text, p.get("id", "")) + if reply: + self._post_reply(reply, root_id=p.get("id")) + logger.info("명령 응답 전송: %s -> %s…", msg_text[:30], reply[:60]) + + self.db.set_kv(self.KV_LAST_SEEN_TS, str(last_seen_ts)) + + except Exception as e: + logger.warning("폴링 예외: %s", e) + + def start(self) -> None: + """백그라운드 스레드로 폴링 시작.""" + if self._running: + return + if not self.bot_token: + logger.warning("MM_BOT_TOKEN 미설정 — Butler 리스너 미시작") + return + if not self._load_channel_id(): + logger.warning("채널 ID 없음 (alias=%s) — Butler 리스너 미시작", self.channel_alias) + return + self._running = True + self._thread = threading.Thread(target=self._poll_loop, daemon=True) + self._thread.start() + logger.info( + "✅ MM Butler 시작 (채널=%s, 폴링=%ds, Claude=%s, Gemini=%s)", + self.channel_alias, + self.poll_interval_sec, + "✓" if claude_client else "✗", + "✓" if gemini_client else "✗", + ) + + def stop(self) -> None: + self._running = False + if self._thread: + self._thread.join(timeout=self.poll_interval_sec * 2) + self._thread = None + logger.info("MM Butler 종료") + + +# ================================================================== +# 진입점 +# ================================================================== + +def main() -> None: + channel_alias = get_env_from_db("MM_BUTLER_CHANNEL", "default").strip() or "default" + poll_sec = get_env_int("MM_BUTLER_POLL_SEC", 15) + + hub = CommandHub( + server_url=MM_SERVER_URL, + bot_token=MM_BOT_TOKEN, + channel_alias=channel_alias, + db=shared_db, + poll_interval_sec=poll_sec, + ) + hub.start() + + logger.info("MM Butler 대기 중… (Ctrl+C 로 종료)") + try: + while True: + time.sleep(60) + except KeyboardInterrupt: + hub.stop() + logger.info("종료") + + +if __name__ == "__main__": + main() diff --git a/mm_remote.py b/mm_remote.py new file mode 100644 index 0000000..5092fd4 --- /dev/null +++ b/mm_remote.py @@ -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 diff --git a/news_analyzer.py b/news_analyzer.py new file mode 100644 index 0000000..95ab8d7 --- /dev/null +++ b/news_analyzer.py @@ -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) diff --git a/quant_bot.db b/quant_bot.db index d9239d1..2877f87 100644 Binary files a/quant_bot.db and b/quant_bot.db differ diff --git a/risk_manager.py b/risk_manager.py index 52d2e08..0680cc5 100644 --- a/risk_manager.py +++ b/risk_manager.py @@ -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( diff --git a/scalping_engine.py b/scalping_engine.py new file mode 100644 index 0000000..95dfe42 --- /dev/null +++ b/scalping_engine.py @@ -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 diff --git a/smart_executor.py b/smart_executor.py new file mode 100644 index 0000000..abc4100 --- /dev/null +++ b/smart_executor.py @@ -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) diff --git a/tail_engine.py b/tail_engine.py new file mode 100644 index 0000000..e7005ee --- /dev/null +++ b/tail_engine.py @@ -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 diff --git a/trend_divergence.py b/trend_divergence.py new file mode 100644 index 0000000..113381f --- /dev/null +++ b/trend_divergence.py @@ -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 diff --git a/update_env_etf.py b/update_env_etf.py new file mode 100644 index 0000000..f28d9cc --- /dev/null +++ b/update_env_etf.py @@ -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() diff --git a/update_env_short.py b/update_env_short.py index 7c9c44c..d2c8c66 100644 --- a/update_env_short.py +++ b/update_env_short.py @@ -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", # 일일 최고가 추격 매수 방지 기준 diff --git a/update_env_simple.py b/update_env_simple.py index 296d355..0965727 100644 --- a/update_env_simple.py +++ b/update_env_simple.py @@ -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 딕셔너리에 값을 입력하세요.") diff --git a/한국투자증권_오픈API_전체문서_20260228_030000.htm b/한국투자증권_오픈API_전체문서_20260228_030000.htm new file mode 100644 index 0000000..d5919b7 --- /dev/null +++ b/한국투자증권_오픈API_전체문서_20260228_030000.htm @@ -0,0 +1,2678 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + <body> + <p> ʽϴ.</p> + </body> + + +