From a433928f3eb0e50c74b37b8574c5a4ced931a45b Mon Sep 17 00:00:00 2001 From: Hwang Date: Fri, 13 Mar 2026 04:37:58 +0900 Subject: [PATCH] 1 --- .cursorrules | 163 ++++ .gitignore | 17 + README.md | 0 upbit_backtest_web.py | 1643 ++++++++++++++++++++++++++++++++++++ upbit_candle_collector.py | 439 ++++++++++ upbit_db_init.py | 226 +++++ upbit_short_ver1.py | 1380 ++++++++++++++++++++++++++++++ upbit_short_ver2.py | 1623 +++++++++++++++++++++++++++++++++++ upbit_tail_param_search.py | 364 ++++++++ 9 files changed, 5855 insertions(+) create mode 100644 .cursorrules create mode 100644 .gitignore create mode 100644 README.md create mode 100644 upbit_backtest_web.py create mode 100644 upbit_candle_collector.py create mode 100644 upbit_db_init.py create mode 100644 upbit_short_ver1.py create mode 100644 upbit_short_ver2.py create mode 100644 upbit_tail_param_search.py diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..20285df --- /dev/null +++ b/.cursorrules @@ -0,0 +1,163 @@ +# 1. 역할 (Role & Persona) +- 당신은 '세계 최고의 퀀트 개발자'이자 '헤지펀드 매니저'입니다. 동시에 초보자를 위한 최고의 코딩 멘토입니다. +- 금융 공학적 관점에서 수익을 극대화하고 리스크를 최소화하는 논리를 제시하되, 설명은 아주 쉽고 친절하게 하세요. + +# 2. 코드 작성 및 제공 원칙 (Strict Rules) +- [전체 코드 제공]: 코드는 반드시 '전체 소스(Full Source)'를 제공하세요. `// ... 생략`은 절대 금지합니다. 함수가 3줄 이하라면 3줄 전체를 제공하고, 수정할 때도 생략 없이 온전한 코드를 줍니다. +- [기존 구조 존중]: 잘 작동하는 코드를 '더 나은 구조'라며 임의로 클래스화하거나 복잡하게 바꾸지 마세요. 기존의 주석과 로거(Logger)는 절대 지우지 말고 유지하세요. +- [설명 후 수정]: 핵심 로직을 변경해야 할 때는 코드를 짜기 전에 반드시 이유를 먼저 설명하고 승인을 받으세요. +- [교육적 주석]: 주식 투자 용어(RSI, 변동성, 꼬리잡기 등)나 새로운 개념이 나올 때는 코딩에 추가할 때 이해하기 쉽게 주석을 달아주세요. +- 빼먹거나 놓치는 부분 없이 꼼꼼하게 검증한 후 코드를 출력하세요. + +# 3. 투자 철학 및 매매 로직 (Trading Philosophy) +- 모든 매매 로직은 '안정성(Safety)'을 최우선으로 하며, 손절(Stop-loss) 로직은 필수입니다. +- 백테스트(Backtest)가 불가능한 '뇌동매매' 기반의 코드는 작성하지 않습니다. +- 수수료(Fees)와 슬리피지(Slippage), API 호출 효율성을 철저히 고려하여 코드를 작성하세요. + +# 4. 시스템 아키텍처 및 통신 (System Architecture) +- [즉시 저장 (Atomic Save)]: 데이터 파일(json 등)은 프로그램 종료 시점이 아니라, 이벤트(알림 발송 등)가 발생할 때마다 즉시 저장하세요. 재시작 시 기존 데이터를 삭제하지 않고 수정 데이터만 끼워 넣는 방식으로 안정적으로 운영하세요. +- [API 요청 규칙]: 모든 API 요청은 `utils/request_handler.py`의 `SafeRequest` 클래스를 상속받아 구현하세요. HTTP 429(Too Many Requests) 에러 발생 시 재시도(Retry) 로직을 반드시 포함하세요. +- [알림 시스템]: 알림 기능은 텔레그램과 매터모스트용(msg_tg, msg_mm)으로 분리하여 구현하고, 서버 부하 방지를 위해 일반 루프에는 `random.sleep(1~3)`을 기본 적용하세요. (실시간 매매 로직 제외) +## [로직 누락 방지 규칙] +- 모든 코드를 작성한 후, 스스로 다음 항목이 포함되었는지 검토하고 대답하세요. +- 1. 손절(Stop-loss) 및 예외 처리 로직이 포함되었는가? +- 2. API 호출 제한(429 Error) 및 슬리피지 고려가 되었는가? +- 3. 사용자가 요청한 기존 로직과 100% 동일한 기능을 수행하는가? +- 만약 하나라도 빠졌다면 코드를 출력하기 전에 스스로 수정하세요. + +# [CRITICAL SYSTEM DIRECTIVES: 절대 엄수 사항 - 위반 시 작동 중지] + +## 1. 🚨 하드코딩 절대 금지 (NO HARDCODING) +- 어떠한 경우에도 코드 내부에 임계값, 비율, 점수, 시간 등의 수치를 직접 하드코딩하지 마라. +- 숫자값을 추가하거나 수정할 때는 **반드시** `get_env_float()`, `get_env_int()`, `get_env_bool()`을 사용하여 DB/Env에서 불러오도록 작성하라. +- 예시: `if rsi > 78:` (X, 절대 금지) / `rsi_limit = get_env_float("RSI_LIMIT", 78.0); if rsi > rsi_limit:` (O, 필수 적용) +- 변수명은 직관적인 대문자 스네이크 케이스(예: `MAX_DROP_RATE`)로 작성하고 기본값을 설정하라. + +## 2. 🧠 맥락적 추론 및 아키텍처 엄수 (SCAN vs TRIGGER 분리) +- 사용자가 "A를 매수 체크 로직으로 옮겨"라고 지시하면, 단순히 A만 옮기지 마라. 사용자의 의도는 **"스캔(Scan) 단계에서는 조건 필터링을 최소화하여 후보를 DB에 최대한 많이 올리고, 실제 매수 직전(Trigger)에 모든 엄격한 필터(보조지표, 호가, 수급 등)를 한 번에 검사하라"**는 뜻이다. +- 무거운 연산(API 추가 호출, 분봉 분석 등)은 절대 5분 주기 스캔 함수에 넣지 말고, 매수 타점 체크 함수에 넣어라. + +## 한툭투자증권 api 사용 규칙 +API Reference +한국투자증권 오픈API는 REST 방식과 Websocket 방식으로 구성됩니다. 각 방식별 호출 도메인은 아래와 같습니다. + +실전투자 + +REST: https://openapi.koreainvestment.com:9443 +Websocket: ws://ops.koreainvestment.com:21000 + +모의투자 + +REST: https://openapivts.koreainvestment.com:29443 +Websocket: ws://ops.koreainvestment.com:31000 + +REST API 호출 시 지원 가능 프로토콜 : TLS 1.2, TLS 1.3 + +※ TLS 1.0과 TLS 1.1 프로토콜은 보안 문제로 2025.12.12(금) 이후 지원하지 않습니다. 해당 프로토콜로 호출 시, 서비스 이용이 불가하오니 반드시 변경 부탁드립니다. + +OAuth 인증 +한국투자 오픈API는 보안코드(appkey, appsecret)를 사용하여 인증합니다. + +REST 방식: 접근토큰(access_token) 발급 +Websocket 방식: 실시간 접속키(approval_key) 발급 +보안코드를 발급받지 않았다면 [서비스 이용안내]를 확인하세요. +종목정보 파일 +주문 및 시세 조회가 가능한 종목정보 마스터파일을 제공합니다. +해당 파일은 당사에서 공통 관리하며 매일 업데이트됩니다. +업데이트 시간: 06:00, 06:55, 07:35, 07:55, 08:45, 09:46, 10:55, 17:10, 17:30, 17:55, 18:10, 18:30, 18:55 +주문/계좌 (REST 방식 - 주문: POST 조회: GET) +주문 및 계좌 조회 API는 매수주문, 매도주문, 정정/취소주문을 처리하는 POST 방식 API와, +계좌의 잔고조회 및 체결내역 조회를 할 수 있는 GET 방식 API로 구성되어 있습니다. +주문 접수 시 장시간 확인하시기 바랍니다. 거래 가능 시간은 한국투자증권 홈페이지에서 확인하실 수 있습니다. +거래 가능 시간: +(국내주식/선물옵션) https://securities.koreainvestment.com/main/customer/guide/_static/TF04ad010000.jsp +(해외주식) https://securities.koreainvestment.com/main/bond/research/_static/TF03ca050001.jsp +(해외선물옵션) https://securities.koreainvestment.com/main/bond/foreign/_static/TF03df010300.jsp +시세 조회 (REST 방식) +기본 시세 (REST): 종목별 기본 시세 조회 API +시세 분석 (REST): 세부 시세 정보 조회 API +ELW 시세 (REST): ELW 종목 시세 조회 API +업종/기타 (REST): 업종 시세 및 기타 정보 조회 API +종목 정보 (REST): 종목별 기본·재무·일정 정보 조회 API +순위 분석 (REST): 순위 정보 조회 API +실시간 시세 (Websocket 방식) +종목별 실시간 체결가, 호가, 예상체결가 등 수신 API +HTS ID 단위 주문 및 체결 통보 API (주문/체결 발생 시 수신) + + +# cursor-ide-agent-claude-sonnet-3.7_20250309 + +source: + +## 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 new file mode 100644 index 0000000..585bfa7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv +.env +.backup +# Environment variables +.env +*.log +*.json +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/upbit_backtest_web.py b/upbit_backtest_web.py new file mode 100644 index 0000000..4e0f296 --- /dev/null +++ b/upbit_backtest_web.py @@ -0,0 +1,1643 @@ +#!/usr/bin/env python3 +""" +upbit_backtest_web.py — 업비트 단타 백테스트 & 대시보드 +========================================================= +실행: python3 upbit_backtest_web.py +접속: http://localhost:6060 + +탭1. 실거래 분석 → trade_history 기반 실제 매매 결과 +탭2. 꼬리잡기 백테스트 → upbit_candles 분봉 가격 재현(Price-Replay) 백테스트 +탭3. ENV 설정 → env_config 조회/수정 (업비트 전용 파라미터) +탭4. 캔들 수집 → 업비트 REST API로 분봉 수집 후 DB 저장 + +DB: MariaDB 192.168.0.141:3306/upbit_quant_db +""" + +import sys, os, json, time, logging, threading, hashlib, uuid +from datetime import datetime, timedelta +from typing import List, Dict, Optional +from urllib.parse import urlencode + +import requests +import pymysql +import pymysql.cursors +from flask import Flask, jsonify, request, render_template_string + +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(message)s", datefmt="%H:%M:%S") +logger = logging.getLogger("upbit_backtest_web") + +app = Flask(__name__) + +# ── MariaDB 접속 정보 ────────────────────────────────────────────────────── +_DB_CFG = dict( + host="192.168.0.141", port=3306, + user="jae", password="1234", + database="upbit_quant_db", + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, +) + + +def _db(): + """요청마다 새 MariaDB 연결 반환""" + conn = pymysql.connect(**_DB_CFG) + return conn + + +# ── 업비트 REST 클라이언트 (캔들 수집용) ────────────────────────────────── +_UPBIT_BASE = "https://api.upbit.com/v1" +_last_req_t = 0.0 + + +def _upbit_get(path: str, params: dict = None, auth_key: str = "", auth_secret: str = "") -> Optional[dict]: + """업비트 REST GET (레이트리밋 0.12초 보호, HTTP 429 재시도)""" + global _last_req_t + elapsed = time.time() - _last_req_t + if elapsed < 0.12: + time.sleep(0.12 - elapsed) + _last_req_t = time.time() + + url = f"{_UPBIT_BASE}{path}" + hdrs = {} + if auth_key and auth_secret: + try: + import jwt as pyjwt + payload = {"access_key": auth_key, "nonce": str(uuid.uuid4())} + qs = urlencode(params or {}, doseq=True) + if qs: + payload["query_hash"] = hashlib.sha512(qs.encode()).hexdigest() + payload["query_hash_alg"] = "SHA512" + hdrs["Authorization"] = f"Bearer {pyjwt.encode(payload, auth_secret, algorithm='HS256')}" + except ImportError: + pass + + for attempt in range(4): + try: + r = requests.get(url, params=params, headers=hdrs, timeout=10) + if r.status_code == 429: + time.sleep(1.5 + attempt) + continue + if r.status_code == 200: + return r.json() + return None + except Exception as e: + time.sleep((2 ** attempt) * 0.5) + return None + + +# ── 캔들 수집 백그라운드 Job 상태 ───────────────────────────────────────── +_fetch_jobs: Dict[str, dict] = {} + + +# ──────────────────────────────────────────────────────────────────────────── +# RSI 시리즈 계산 헬퍼 +# ──────────────────────────────────────────────────────────────────────────── + +def _compute_rsi_series(closes: list, period: int = 14) -> list: + """RSI 시리즈 계산 (Wilder 스무딩)""" + 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_g = sum(gains[:period]) / period + avg_l = sum(losses[:period]) / period + for i in range(period, len(closes)): + idx = i - 1 + if i > period: + avg_g = (avg_g * (period-1) + gains[idx]) / period + avg_l = (avg_l * (period-1) + losses[idx]) / period + rs = avg_g / avg_l if avg_l > 0 else float("inf") + rsi_list[i] = 100 - (100 / (1 + rs)) if avg_l > 0 else 100.0 + return rsi_list + + +# ──────────────────────────────────────────────────────────────────────────── +# API: 실거래 분석 +# ──────────────────────────────────────────────────────────────────────────── + +@app.route("/api/actual", methods=["GET"]) +def api_actual(): + """trade_history 기반 실제 매매 성과 분석""" + strategy = request.args.get("strategy", "UPBIT_TAIL_CATCH") + start = request.args.get("start", "") + end = request.args.get("end", "") + + conn = _db() + try: + cur = conn.cursor() + params = [strategy] + sql = "SELECT * FROM trade_history WHERE strategy = %s" + if start: + sql += " AND sell_date >= %s" + params.append(start + " 00:00:00") + if end: + sql += " AND sell_date <= %s" + params.append(end + " 23:59:59") + sql += " ORDER BY sell_date ASC" + cur.execute(sql, params) + trades = cur.fetchall() or [] + + # 날짜 직렬화 + for t in trades: + for k in ("buy_date", "sell_date"): + if t.get(k): + t[k] = str(t[k]) + + # 누적 손익 계산 + equity, cum_pnl = [], 0.0 + for t in trades: + cum_pnl += float(t.get("realized_pnl") or 0) + equity.append({ + "date": str(t.get("sell_date") or "")[:10], + "cum_pnl": round(cum_pnl), + "pnl": round(float(t.get("realized_pnl") or 0)), + }) + + total = len(trades) + wins = [t for t in trades if float(t.get("realized_pnl") or 0) > 0] + losses = [t for t in trades if float(t.get("realized_pnl") or 0) < 0] + total_pnl = sum(float(t.get("realized_pnl") or 0) for t in trades) + avg_hold = (sum(float(t.get("hold_minutes") or 0) for t in trades) / total) if total else 0 + win_pnl = sum(float(t.get("realized_pnl") or 0) for t in wins) + loss_pnl = sum(float(t.get("realized_pnl") or 0) for t in losses) + pf = round(abs(win_pnl / loss_pnl), 2) if loss_pnl != 0 else 9999.0 + + # MDD + peak, mdd, cum = 0.0, 0.0, 0.0 + for t in trades: + cum += float(t.get("realized_pnl") or 0) + if cum > peak: peak = cum + dd = peak - cum + if dd > mdd: mdd = dd + + # 매도 이유별 집계 + reasons: Dict[str, int] = {} + for t in trades: + r = t.get("sell_reason") or "기타" + reasons[r] = reasons.get(r, 0) + 1 + + # 일별 P&L + daily: Dict[str, float] = {} + for t in trades: + day = str(t.get("sell_date") or "")[:10] + if day: + daily[day] = daily.get(day, 0) + float(t.get("realized_pnl") or 0) + daily_list = [{"date": d, "pnl": round(v)} for d, v in sorted(daily.items())] + + # 종목별 상위 손익 + code_pnl: Dict[str, float] = {} + code_name: Dict[str, str] = {} + for t in trades: + c = t["code"] + code_pnl[c] = code_pnl.get(c, 0) + float(t.get("realized_pnl") or 0) + code_name[c] = t.get("name") or c + top_codes = sorted(code_pnl.items(), key=lambda x: x[1], reverse=True)[:10] + top_list = [{"code": c, "name": code_name[c], "pnl": round(v)} for c, v in top_codes] + + return jsonify({ + "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_min": round(avg_hold, 1), + "profit_factor": pf, + "max_drawdown": round(mdd), + }, + "equity": equity, + "daily": daily_list, + "reasons": reasons, + "top_codes": top_list, + "trades": trades[-200:], + }) + finally: + conn.close() + + +# ──────────────────────────────────────────────────────────────────────────── +# API: 꼬리잡기 백테스트 (upbit_candles 기반) +# ──────────────────────────────────────────────────────────────────────────── + +@app.route("/api/backtest/tail", methods=["GET"]) +def api_backtest_tail(): + """ + 업비트 꼬리잡기 전략 가격 재현 백테스트. + entry 조건: 당일 낙폭 + 회복률 + 망치봉 꼬리 + RSI + exit 조건: 손절 / 익절 / 어깨 컷(trailing) / 24h 강제청산(코인은 24h 장) + """ + start = request.args.get("start", "") + end = request.args.get("end", "") + # 코인 필터: 콤마 구분으로 특정 마켓만 선택 (빈 값이면 전체) + market_filter = request.args.get("markets", "").strip().upper() + 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 = float(request.args.get("max_rec", 80)) / 100 + 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 + slot_money = float(request.args.get("slot_money", 100_000)) + fee_rate = float(request.args.get("fee_rate", 0.05)) / 100 # 업비트 0.05% 편도 + cooldown_min = int( request.args.get("cooldown_min", 30)) + max_hold_hours = float(request.args.get("max_hold_hours", 24)) # 코인: 24시간 강제청산 + max_daily = int( request.args.get("max_daily", 5)) + timeframe = int( request.args.get("timeframe", 3)) # 분봉 단위 (기본 3분봉) + + conn = _db() + try: + start_key = (start.replace("-", "") + "0000") if start else "20260101" + end_key = (end.replace("-", "") + "2359") if end else "99991231" + + cur = conn.cursor() + + # 코인 필터 적용: 특정 마켓만 선택하거나 전체 조회 + filter_list = [m.strip() for m in market_filter.split(",") if m.strip()] if market_filter else [] + if filter_list: + fmt = ",".join(["%s"] * len(filter_list)) + cur.execute( + f"SELECT DISTINCT code FROM upbit_candles " + f"WHERE timeframe=%s AND candle_time >= %s AND candle_time <= %s " + f"AND code IN ({fmt}) ORDER BY code", + [timeframe, start_key, end_key] + filter_list + ) + else: + cur.execute( + "SELECT DISTINCT code FROM upbit_candles " + "WHERE timeframe=%s AND candle_time >= %s AND candle_time <= %s ORDER BY code", + [timeframe, start_key, end_key] + ) + codes = [r["code"] for r in (cur.fetchall() or [])] + + all_trades: List[Dict] = [] + + def _t2dt(t: str) -> datetime: + """YYYYMMDDHHMI → datetime""" + return datetime.strptime(t, "%Y%m%d%H%M") + + for code in codes: + cur.execute( + "SELECT candle_time, open_price, high_price, low_price, close_price, volume " + "FROM upbit_candles " + "WHERE timeframe=%s AND code=%s " + "AND candle_time >= %s AND candle_time <= %s " + "AND is_confirmed=1 " + "ORDER BY candle_time ASC", + [timeframe, code, start_key, end_key] + ) + rows = cur.fetchall() or [] + if len(rows) < rsi_period + 5: + continue + + candles = [dict(r) for r in rows] + closes = [float(c["close_price"]) for c in candles] + rsis = _compute_rsi_series(closes, rsi_period) + + 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 + + for i in range(rsi_period + 1, len(candles)): + c = candles[i] + day = c["candle_time"][:8] + op = float(c["open_price"]) + hi = float(c["high_price"]) + lo = float(c["low_price"]) + cl = float(c["close_price"]) + + # ── 당일 누적 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) + + # ── 포지션 보유 중: 청산 체크 ───────────────────────────── + if position is not None: + max_p = max(position["max_price"], hi) + position["max_price"] = max_p + + reason = None + exit_price = cl + + # 손절: 캔들 저가가 손절선 이하 + if lo <= position["stop"]: + reason = "손절" + exit_price = position["stop"] + # 익절: 캔들 고가가 익절선 이상 + elif hi >= position["target"]: + reason = "익절" + exit_price = position["target"] + # 어깨매도: 고점에서 shoulder_cut_pct 하락 (수익 보존) + elif max_p >= position["entry_price"] * (1 + shoulder_min_high): + trail_stop = max_p * (1 - shoulder_cut_pct) + if cl <= trail_stop: + reason = "어깨매도" + exit_price = cl + # 24시간 강제청산 (코인은 장 마감 없음 → max_hold_hours로 대체) + if reason is None: + held_hours = (_t2dt(c["candle_time"]) - + _t2dt(position["entry_time"])).total_seconds() / 3600 + if held_hours >= max_hold_hours: + reason = "보유시간초과" + exit_price = cl + + if reason: + qty = position["qty"] + buy_amt = position["entry_price"] * qty + sell_amt = exit_price * qty + # 업비트 수수료: 매수/매도 각각 fee_rate (증권거래세 없음) + pnl = (sell_amt - buy_amt + - buy_amt * fee_rate + - sell_amt * fee_rate) + 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, 4), + "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 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_threshold: + continue + + # 낙폭 필터 (look-ahead 없는 running_low 사용) + if running_open <= 0: + continue + drop_rate = (running_open - running_low) / running_open + if drop_rate < min_drop_rate: + continue + + # 회복률 필터 + day_range = running_high - running_low + if day_range <= 0: + continue + recovery = (cl - running_low) / day_range + if not (min_recovery_ratio <= recovery <= max_rec): + continue + + # 망치봉 꼬리 계산 + body_top = max(op, cl) + body_bottom = min(op, cl) + body_len = max(body_top - body_bottom, 1.0) + tail_len = max(body_bottom - lo, 0.0) + tail_ratio = tail_len / body_len + tail_pct = tail_len / lo if lo > 0 else 0 + + if tail_ratio < tail_ratio_min or tail_pct < tail_pct_min: + continue + + # 고점 추격 방지 + if cl >= running_high * high_chase_thr: + continue + + # 진입 가격: 신호봉 다음봉 시가 (현실적 체결 가정) + if i + 1 >= len(candles): + continue + next_c = candles[i + 1] + entry_price = float(next_c["open_price"]) + if entry_price <= 0: + continue + + qty = 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"]) + + equity = [] + cum = 0.0 + for t in all_trades: + cum += t["pnl"] + equity.append({"date": t["sell_time"][:8], "cum_pnl": round(cum), "pnl": t["pnl"]}) + + total = len(all_trades) + wins = [t for t in all_trades if t["pnl"] > 0] + losses = [t for t in all_trades if t["pnl"] < 0] + total_pnl = sum(t["pnl"] for t in all_trades) + avg_hold = (sum(t["hold_min"] for t in all_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 all_trades: + cum += t["pnl"] + if cum > peak: peak = cum + dd = peak - cum + if dd > mdd: mdd = dd + + reasons: Dict[str, int] = {} + for t in all_trades: + reasons[t["sell_reason"]] = reasons.get(t["sell_reason"], 0) + 1 + + daily: Dict[str, float] = {} + for t in all_trades: + d8 = t["sell_time"][:8] + daily[d8] = daily.get(d8, 0) + t["pnl"] + daily_list = [{"date": d[:4]+"-"+d[4:6]+"-"+d[6:], "pnl": round(v)} + for d, v in sorted(daily.items())] + + return jsonify({ + "params": { + "rsi_period": rsi_period, + "rsi_threshold": rsi_threshold, + "min_drop_rate": min_drop_rate * 100, + "min_recovery": min_recovery_ratio * 100, + "sl_pct": sl_pct * 100, + "tp_pct": tp_pct * 100, + "shoulder_cut": shoulder_cut_pct * 100, + "slot_money": slot_money, + "fee_rate": fee_rate * 100, + "timeframe": timeframe, + "codes_analyzed": len(codes), + }, + "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_min": round(avg_hold, 1), + "profit_factor": pf, + "max_drawdown": round(mdd), + }, + "equity": equity, + "daily": daily_list, + "reasons": reasons, + "trades": all_trades[-200:], + }) + finally: + conn.close() + + +# ──────────────────────────────────────────────────────────────────────────── +# API: ENV 설정 조회/수정 +# ──────────────────────────────────────────────────────────────────────────── + +@app.route("/api/env", methods=["GET"]) +def api_env_get(): + """env_config 최신 row 조회""" + conn = _db() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1") + row = cur.fetchone() + if not row: + return jsonify({}) + result = {k: v for k, v in row.items() if k not in ("id", "created_at")} + return jsonify(result) + finally: + conn.close() + + +@app.route("/api/env", methods=["POST"]) +def api_env_set(): + """env_config 업데이트 (제출된 필드만 수정)""" + body = request.get_json(force=True) or {} + if not body: + return jsonify({"error": "빈 요청"}), 400 + + # 허용된 컬럼만 업데이트 (보안) + conn = _db() + try: + cur = conn.cursor() + cur.execute("DESCRIBE env_config") + allowed_cols = {r["Field"] for r in (cur.fetchall() or [])} - {"id", "created_at"} + + sets, vals = [], [] + for k, v in body.items(): + if k in allowed_cols: + sets.append(f"`{k}` = %s") + vals.append(str(v)) + + if not sets: + return jsonify({"error": "유효한 필드 없음"}), 400 + + # 최신 row 업데이트 + cur.execute("SELECT id FROM env_config ORDER BY id DESC LIMIT 1") + row = cur.fetchone() + if row: + vals.append(row["id"]) + cur.execute(f"UPDATE env_config SET {', '.join(sets)} WHERE id = %s", vals) + else: + cols = ", ".join(f"`{k}`" for k in body.keys() if k in allowed_cols) + marks = ", ".join(["%s"] * len(sets)) + cur.execute(f"INSERT INTO env_config ({cols}) VALUES ({marks})", vals) + + return jsonify({"ok": True, "updated": len(sets)}) + finally: + conn.close() + + +# ──────────────────────────────────────────────────────────────────────────── +# API: 캔들 수집 (업비트 REST → upbit_candles DB 저장) +# ──────────────────────────────────────────────────────────────────────────── + +@app.route("/api/candles/fetch", methods=["POST"]) +def api_candles_fetch(): + """ + 업비트 REST API로 지정 마켓/기간/분봉 수집 후 DB 저장 + body: {market: "KRW-BTC", start: "2026-01-01", end: "2026-03-09", timeframe: 3} + """ + body = request.get_json(force=True) or {} + market = body.get("market", "").upper() + start_str = body.get("start", "") + end_str = body.get("end", datetime.now().strftime("%Y-%m-%d")) + tf = int(body.get("timeframe", 3)) + markets = body.get("markets", []) # 여러 마켓 동시 수집 + + if not market and not markets: + return jsonify({"error": "market 또는 markets 필드 필수"}), 400 + + target_markets = markets if markets else [market] + job_id = str(uuid.uuid4())[:8] + _fetch_jobs[job_id] = {"status": "running", "progress": 0, "total": 0, "saved": 0, "errors": []} + + def _worker(): + total_saved = 0 + conn_w = _db() + try: + cur_w = conn_w.cursor() + for mkt in target_markets: + # 수집 기간 설정 (업비트는 최신순 200봉씩 제공) + if start_str: + start_dt = datetime.strptime(start_str, "%Y-%m-%d") + else: + start_dt = datetime.now() - timedelta(days=7) + end_dt = datetime.strptime(end_str, "%Y-%m-%d") + timedelta(days=1) + + # to 파라미터를 줄여가며 페이지네이션 수집 + to_dt = end_dt + count = 0 + + while to_dt > start_dt: + to_str = to_dt.strftime("%Y-%m-%dT%H:%M:%S") + data = _upbit_get( + f"/candles/minutes/{tf}", + {"market": mkt, "to": to_str, "count": 200} + ) + if not data: + break + + batch = [] + oldest_dt = to_dt + for c in data: + # candle_date_time_kst: "2026-03-09T14:30:00" + cdt_str = c.get("candle_date_time_kst", "")[:16] + if not cdt_str: + continue + try: + cdt = datetime.strptime(cdt_str, "%Y-%m-%dT%H:%M") + except Exception: + continue + if cdt < start_dt: + continue + ct = cdt.strftime("%Y%m%d%H%M") + batch.append(( + mkt, ct, tf, + float(c.get("opening_price", 0)), + float(c.get("high_price", 0)), + float(c.get("low_price", 0)), + float(c.get("trade_price", 0)), + float(c.get("candle_acc_trade_volume", 0)), + 1, + )) + if cdt < oldest_dt: + oldest_dt = cdt + + if batch: + cur_w.executemany(""" + INSERT INTO upbit_candles + (code, candle_time, timeframe, open_price, high_price, + low_price, close_price, volume, is_confirmed) + VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s) + ON DUPLICATE KEY UPDATE + open_price=VALUES(open_price), high_price=VALUES(high_price), + low_price=VALUES(low_price), close_price=VALUES(close_price), + volume=VALUES(volume) + """, batch) + total_saved += len(batch) + count += len(batch) + + # 다음 페이지: oldest_dt - 1분 + to_dt = oldest_dt - timedelta(minutes=tf) + if to_dt <= start_dt: + break + time.sleep(0.15) + + _fetch_jobs[job_id]["progress"] += 1 + _fetch_jobs[job_id]["saved"] = total_saved + + except Exception as e: + _fetch_jobs[job_id]["errors"].append(str(e)) + logger.error(f"캔들 수집 오류: {e}") + finally: + conn_w.close() + _fetch_jobs[job_id]["status"] = "done" + + t = threading.Thread(target=_worker, daemon=True) + t.start() + return jsonify({"ok": True, "job_id": job_id, "markets": target_markets}) + + +@app.route("/api/candles/fetch/status/", methods=["GET"]) +def api_fetch_status(job_id: str): + """캔들 수집 진행 상태 조회""" + job = _fetch_jobs.get(job_id) + if not job: + return jsonify({"error": "job_id 없음"}), 404 + return jsonify(job) + + +@app.route("/api/candles/markets", methods=["GET"]) +def api_candles_markets(): + """upbit_candles에 저장된 마켓 목록 조회""" + tf = request.args.get("timeframe", "3") + conn = _db() + try: + cur = conn.cursor() + cur.execute( + "SELECT code, COUNT(*) as cnt, MIN(candle_time) as first_dt, MAX(candle_time) as last_dt " + "FROM upbit_candles WHERE timeframe=%s GROUP BY code ORDER BY code", + [tf] + ) + return jsonify(cur.fetchall() or []) + finally: + conn.close() + + +@app.route("/api/candles/upbit_markets", methods=["GET"]) +def api_upbit_markets(): + """업비트 KRW 마켓 목록 조회 (실시간)""" + data = _upbit_get("/market/all") + if not data: + return jsonify([]) + return jsonify([m["market"] for m in data if m["market"].startswith("KRW-")]) + + +# ──────────────────────────────────────────────────────────────────────────── +# API: 보유 현황 / 후보 목록 +# ──────────────────────────────────────────────────────────────────────────── + +@app.route("/api/holdings", methods=["GET"]) +def api_holdings(): + """현재 보유 포지션 조회""" + conn = _db() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM active_trades WHERE status='HOLDING' ORDER BY buy_date DESC") + rows = cur.fetchall() or [] + for r in rows: + for k in ("buy_date", "updated_at"): + if r.get(k): + r[k] = str(r[k]) + return jsonify(rows) + finally: + conn.close() + + +@app.route("/api/candidates", methods=["GET"]) +def api_candidates(): + """최근 스캔 후보 목록""" + conn = _db() + try: + cur = conn.cursor() + cur.execute("SELECT * FROM target_candidates ORDER BY score DESC LIMIT 50") + rows = cur.fetchall() or [] + for r in rows: + for k in ("scan_time", "updated_at"): + if r.get(k): + r[k] = str(r[k]) + return jsonify(rows) + finally: + conn.close() + + +# ──────────────────────────────────────────────────────────────────────────── +# HTML 대시보드 템플릿 +# ──────────────────────────────────────────────────────────────────────────── + +HTML_TEMPLATE = """ + + + + +Upbit 단타 봇 V2 — 대시보드 + + + + + + + + +
+ + +
+
+
+
+
+
+
+
+
+
+ +
+ +
+
+
누적 손익 (원)
+
+
+
+
일별 P&L
+
+
+
+ +
+
최근 매매 내역 (최대 200건)
+
+ + + + + + + +
매수시각매도시각종목매수가매도가수량손익(원)수익률보유(분)사유
+
+
+
+ + + + + + + + + + + + + +
+ +
+ + + +""" + + +@app.route("/") +def index(): + return render_template_string(HTML_TEMPLATE) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=6060, debug=False) diff --git a/upbit_candle_collector.py b/upbit_candle_collector.py new file mode 100644 index 0000000..32d1d18 --- /dev/null +++ b/upbit_candle_collector.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +""" +upbit_candle_collector.py — 업비트 분봉 수집기 (과거 데이터 페이지네이션) +========================================================================== +업비트 REST API: GET /v1/candles/minutes/{unit} + - 인증 불필요 (Public API) + - 1회 최대 200봉 반환 + - `to` 파라미터로 기준 시각 이전 봉 반환 → 줄여가며 전체 수집 + +지원 분봉 단위: 1, 3, 5, 10, 15, 30, 60, 240 + +실행 예시: + # KRW-BTC 3분봉, 최근 7일 + python3 upbit_candle_collector.py --market KRW-BTC --unit 3 + + # 여러 마켓, 60분봉, 기간 지정 + python3 upbit_candle_collector.py --market KRW-BTC,KRW-ETH,KRW-SOL --unit 60 --start 2026-01-01 + + # 전체 KRW 마켓 3분봉 자동 수집 + python3 upbit_candle_collector.py --all-krw --unit 3 --start 2026-03-01 + + # DB 저장 없이 CSV만 출력 + python3 upbit_candle_collector.py --market KRW-BTC --unit 1 --csv + +레이트리밋 (업비트): + - 시세 API: 분당 600회 → 요청 간 0.12초 대기 (내장) +""" + +import os +import sys +import time +import csv +import argparse +import logging +from datetime import datetime, timedelta +from typing import List, Optional + +import requests +import pymysql +import pymysql.cursors + +logging.basicConfig( + format="[%(asctime)s] %(message)s", + datefmt="%H:%M:%S", + level=logging.INFO, +) +logger = logging.getLogger("UpbitCollector") + +# ── MariaDB 접속 정보 ────────────────────────────────────────────────────── +_DB_CFG = dict( + host="192.168.0.141", port=3306, + user="jae", password="1234", + database="upbit_quant_db", + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, +) + +# 업비트 지원 분봉 단위 +VALID_UNITS = {1, 3, 5, 10, 15, 30, 60, 240} + +_UPBIT_BASE = "https://api.upbit.com/v1" +_last_req = 0.0 + + +def _get(path: str, params: dict = None, max_retry: int = 5) -> Optional[list]: + """ + 업비트 REST GET 요청 + - 레이트리밋: 분당 600회 → 0.12초 간격 유지 + - HTTP 429(Too Many Requests): 점진적 대기 후 재시도 + - 네트워크 오류: 지수 백오프 재시도 + """ + global _last_req + # 레이트리밋 보호: 0.12초 미만이면 대기 + elapsed = time.time() - _last_req + if elapsed < 0.12: + time.sleep(0.12 - elapsed) + _last_req = time.time() + + url = f"{_UPBIT_BASE}{path}" + for attempt in range(max_retry): + try: + r = requests.get(url, params=params, timeout=10) + + # HTTP 429: Too Many Requests → 점진적 대기 + if r.status_code == 429: + wait = 1.0 + attempt * 1.5 + logger.warning(f"⏳ HTTP 429 — {wait:.1f}초 대기 (attempt {attempt+1})") + time.sleep(wait) + continue + + if r.status_code == 200: + return r.json() + + logger.warning(f"⚠️ HTTP {r.status_code}: {r.text[:200]}") + return None + + except requests.RequestException as e: + wait = (2 ** attempt) * 0.5 + logger.warning(f"⚠️ 네트워크 오류 ({attempt+1}/{max_retry}): {e} → {wait:.1f}초") + time.sleep(wait) + + return None + + +def fetch_candles( + market: str, + unit: int, + start_dt: datetime, + end_dt: datetime, +) -> List[dict]: + """ + 업비트 분봉 페이지네이션 수집 + - 업비트는 최신봉이 index 0 (역순) 으로 반환 + - `to` 파라미터: 이 시각(exclusive) 이전의 봉 반환 + - 한 번에 최대 200봉 → `to`를 과거로 당겨가며 반복 + + Args: + market : KRW-BTC 등 마켓코드 + unit : 분봉 단위 (1/3/5/10/15/30/60/240) + start_dt : 수집 시작 시각 + end_dt : 수집 종료 시각 + + Returns: + [{candle_time, open, high, low, close, volume}, ...] (시간 오름차순) + """ + if unit not in VALID_UNITS: + raise ValueError(f"지원하지 않는 단위: {unit} (허용: {sorted(VALID_UNITS)})") + + all_candles = [] + # 수집 기준 시각: end_dt부터 역방향으로 + to_dt = end_dt + timedelta(minutes=unit) # exclusive라 unit 하나 더함 + + logger.info(f"📥 [{market}] {unit}분봉 수집 시작: {start_dt:%Y-%m-%d} ~ {end_dt:%Y-%m-%d}") + page = 0 + + while to_dt > start_dt: + to_str = to_dt.strftime("%Y-%m-%dT%H:%M:%S") + data = _get(f"/candles/minutes/{unit}", { + "market": market, + "to": to_str, + "count": 200, + }) + + if not data: + logger.warning(f" [{market}] 데이터 없음 (to={to_str})") + break + + batch = [] + oldest = to_dt + + for c in data: + # candle_date_time_kst: "2026-03-09T14:30:00" + kst_str = c.get("candle_date_time_kst", "")[:16] + if not kst_str: + continue + try: + cdt = datetime.strptime(kst_str, "%Y-%m-%dT%H:%M") + except ValueError: + continue + + if cdt < start_dt: + continue + + if cdt < oldest: + oldest = cdt + + batch.append({ + "code": market, + "candle_time": cdt.strftime("%Y%m%d%H%M"), + "timeframe": unit, + "open": float(c.get("opening_price", 0)), + "high": float(c.get("high_price", 0)), + "low": float(c.get("low_price", 0)), + "close": float(c.get("trade_price", 0)), + "volume": float(c.get("candle_acc_trade_volume", 0)), + }) + + all_candles.extend(batch) + page += 1 + + if batch: + logger.info(f" [{market}] 페이지 {page}: +{len(batch)}봉 (누계 {len(all_candles)}봉) oldest={oldest:%Y-%m-%d %H:%M}") + + # 다음 페이지: 이번 배치에서 가장 과거 봉의 1분 전 + if oldest <= start_dt: + break # start_dt 이전까지 수집 완료 + + to_dt = oldest - timedelta(minutes=1) + + # API가 200봉 미만 반환 → 더 이상 데이터 없음 + if len(data) < 200: + logger.info(f" [{market}] 200봉 미만 반환 → 수집 완료") + break + + # 시간 오름차순 정렬 + all_candles.sort(key=lambda x: x["candle_time"]) + logger.info(f"✅ [{market}] {unit}분봉 {len(all_candles):,}봉 수집 완료") + return all_candles + + +def save_to_db(candles: List[dict], conn) -> int: + """ + 수집한 캔들을 upbit_candles 테이블에 upsert 저장 + 중복 봉은 OHLCV 업데이트 (ON DUPLICATE KEY UPDATE) + """ + if not candles: + return 0 + + cur = conn.cursor() + sql = """ + INSERT INTO upbit_candles + (code, candle_time, timeframe, open_price, high_price, + low_price, close_price, volume, is_confirmed) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE + open_price = VALUES(open_price), + high_price = VALUES(high_price), + low_price = VALUES(low_price), + close_price = VALUES(close_price), + volume = VALUES(volume) + """ + batch = [ + (c["code"], c["candle_time"], c["timeframe"], + c["open"], c["high"], c["low"], c["close"], c["volume"]) + for c in candles + ] + cur.executemany(sql, batch) + return len(batch) + + +def save_to_csv(candles: List[dict], path: str): + """수집한 캔들을 CSV로 저장""" + if not candles: + return + with open(path, "w", newline="", encoding="utf-8-sig") as f: + writer = csv.DictWriter(f, fieldnames=["code","candle_time","timeframe","open","high","low","close","volume"]) + writer.writeheader() + writer.writerows(candles) + logger.info(f"💾 CSV 저장: {path} ({len(candles):,}봉)") + + +def get_all_krw_markets() -> List[str]: + """업비트 전체 KRW 마켓 목록 조회""" + data = _get("/market/all") + if not data: + return [] + markets = [m["market"] for m in data if m["market"].startswith("KRW-")] + logger.info(f"📋 KRW 마켓 {len(markets)}개 로드") + return markets + + +def get_top_volume_markets(top_n: int) -> List[str]: + """ + 24시간 거래대금 기준 상위 N개 KRW 마켓 반환 + - /v1/ticker API: acc_trade_price_24h (24h KRW 거래대금) 기준 내림차순 정렬 + - 백테스트용으로 유동성이 충분한 마켓만 선별하는 데 사용 + """ + # 전체 KRW 마켓 목록 먼저 조회 + all_markets = get_all_krw_markets() + if not all_markets: + return [] + + # ticker API는 한 번에 최대 100개 마켓 조회 가능 → 배치 처리 + BATCH = 100 + tickers = [] + for i in range(0, len(all_markets), BATCH): + batch = all_markets[i:i + BATCH] + result = _get("/ticker", {"markets": ",".join(batch)}) + if result: + tickers.extend(result) + time.sleep(0.15) # 레이트리밋 방지 + + if not tickers: + logger.warning("⚠️ 티커 조회 실패 — 전체 마켓 반환") + return all_markets + + # 24h 거래대금 내림차순 정렬 + tickers.sort(key=lambda x: float(x.get("acc_trade_price_24h", 0)), reverse=True) + + top = [t["market"] for t in tickers[:top_n]] + logger.info(f"🏆 거래대금 상위 {top_n}개 마켓:") + for rank, t in enumerate(tickers[:top_n], 1): + vol_b = float(t.get("acc_trade_price_24h", 0)) / 1e8 # 억원 + logger.info(f" {rank:>2}. {t['market']:<15} 24h거래대금 {vol_b:>8,.1f}억원") + return top + + +def _estimate_time(n_markets: int, unit: int, start_dt: datetime, end_dt: datetime) -> str: + """수집 예상 시간 계산 (대략적인 추정)""" + days = (end_dt - start_dt).days + 1 + candles = days * 24 * 60 // unit # 총 예상 봉 수 + pages = candles / 200 # 페이지 수 (200봉/요청) + sec_each = pages * 0.13 # 요청당 ~0.13초 + total_sec = n_markets * (sec_each + 0.5) # 마켓 간 0.5초 딜레이 + if total_sec < 60: + return f"{total_sec:.0f}초" + elif total_sec < 3600: + return f"{total_sec/60:.0f}분" + else: + return f"{total_sec/3600:.1f}시간" + + +# ──────────────────────────────────────────────────────────────────────────── +# CLI 진입점 +# ──────────────────────────────────────────────────────────────────────────── + +def main(): + today = datetime.now().strftime("%Y-%m-%d") + month_ago = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + + parser = argparse.ArgumentParser( + description="업비트 분봉 수집기 (과거 데이터 페이지네이션)", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +예시: + # ★ 가장 쉬운 백테스트 준비: 거래대금 상위 30개 마켓, 3분봉, 최근 30일 + python3 upbit_candle_collector.py --top-volume 30 --unit 3 + + # 거래대금 상위 50개, 1분봉, 2개월치 + python3 upbit_candle_collector.py --top-volume 50 --unit 1 --start 2026-01-01 + + # 특정 마켓만 + python3 upbit_candle_collector.py --market KRW-BTC,KRW-ETH,KRW-SOL --unit 3 + + # 전체 KRW 마켓 (시간 매우 오래 걸림) + python3 upbit_candle_collector.py --all-krw --unit 3 --start 2026-03-01 + + # CSV로만 저장 (DB 저장 안 함) + python3 upbit_candle_collector.py --market KRW-BTC --unit 1 --csv --no-db + """, + ) + parser.add_argument("--market", default="", help="마켓코드 (콤마 구분, 예: KRW-BTC,KRW-ETH)") + parser.add_argument("--all-krw", action="store_true", help="전체 KRW 마켓 수집") + parser.add_argument("--top-volume", default=0, type=int, metavar="N", + help="24h 거래대금 상위 N개 마켓 자동 선택 (백테스트 권장)") + parser.add_argument("--unit", default=3, type=int, help=f"분봉 단위 {sorted(VALID_UNITS)}") + parser.add_argument("--start", default=month_ago, help="시작일 (YYYY-MM-DD, 기본: 30일 전)") + parser.add_argument("--end", default=today, help="종료일 (YYYY-MM-DD, 기본: 오늘)") + parser.add_argument("--csv", action="store_true", help="CSV 파일로도 저장") + parser.add_argument("--no-db", action="store_true", help="DB 저장 안 함 (CSV만)") + args = parser.parse_args() + + # 입력 검증 + if args.unit not in VALID_UNITS: + print(f"❌ 지원하지 않는 단위: {args.unit} 허용값: {sorted(VALID_UNITS)}") + sys.exit(1) + + # 마켓 목록 결정 + if args.top_volume > 0: + # ★ 거래대금 상위 N개 자동 선택 (백테스트 권장) + markets = get_top_volume_markets(args.top_volume) + elif args.all_krw: + markets = get_all_krw_markets() + elif args.market: + markets = [m.strip().upper() for m in args.market.split(",") if m.strip()] + else: + print("❌ --top-volume N / --market / --all-krw 중 하나 필요") + parser.print_help() + sys.exit(1) + + if not markets: + print("❌ 수집할 마켓이 없습니다.") + sys.exit(1) + + start_dt = datetime.strptime(args.start, "%Y-%m-%d") + end_dt = datetime.strptime(args.end, "%Y-%m-%d") + timedelta(hours=23, minutes=59) + days = (end_dt - start_dt).days + 1 + + # 예상 시간 미리 출력 + eta = _estimate_time(len(markets), args.unit, start_dt, end_dt) + logger.info(f"📅 수집 기간: {args.start} ~ {args.end} ({days}일)") + logger.info(f"📊 대상 마켓: {len(markets)}개 | 분봉: {args.unit}분 | 예상 소요: {eta}") + logger.info(f"📋 마켓 목록: {', '.join(markets)}") + + # DB 연결 (--no-db 아닌 경우) + conn = None + if not args.no_db: + try: + conn = pymysql.connect(**_DB_CFG) + logger.info(f"✅ MariaDB 연결 완료 (upbit_quant_db)") + except Exception as e: + logger.error(f"❌ DB 연결 실패: {e}") + if not args.csv: + sys.exit(1) + + total_saved = 0 + csv_dir = os.path.dirname(os.path.abspath(__file__)) + t_start = time.time() + + try: + for i, market in enumerate(markets): + elapsed = time.time() - t_start + if i > 0: + avg_sec = elapsed / i + remain = avg_sec * (len(markets) - i) + eta_str = f" 남은시간 약 {remain/60:.0f}분" if remain >= 60 else f" 남은시간 약 {remain:.0f}초" + else: + eta_str = "" + logger.info(f"\n[{i+1}/{len(markets)}] ── {market} {args.unit}분봉 ──{eta_str}") + + candles = fetch_candles(market, args.unit, start_dt, end_dt) + + if not candles: + logger.warning(f" [{market}] 수집된 봉 없음 — 스킵") + continue + + # DB 저장 + if conn and not args.no_db: + saved = save_to_db(candles, conn) + total_saved += saved + logger.info(f" [{market}] DB 저장: {saved:,}봉") + + # CSV 저장 + if args.csv or args.no_db: + csv_path = os.path.join( + csv_dir, + f"{market.replace('-','_')}_{args.unit}m_{args.start}_{args.end}.csv" + ) + save_to_csv(candles, csv_path) + + # 마켓 간 딜레이 (API 부하 방지) + if i < len(markets) - 1: + time.sleep(0.5) + + finally: + if conn: + conn.close() + + elapsed_total = time.time() - t_start + logger.info( + f"\n🎉 완료! 총 DB 저장: {total_saved:,}봉 ({args.unit}분봉) " + f"| 소요시간: {elapsed_total/60:.1f}분" + ) + + +if __name__ == "__main__": + main() diff --git a/upbit_db_init.py b/upbit_db_init.py new file mode 100644 index 0000000..ecf68d2 --- /dev/null +++ b/upbit_db_init.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +upbit_db_init.py — upbit_quant_db MariaDB 테이블 초기화 스크립트 +================================================================= +실행: python3 upbit_db_init.py +- 필요한 테이블이 없으면 생성 (IF NOT EXISTS → 재실행 안전) +- env_config에 초기 기본값 row 삽입 +""" + +import sys +import pymysql +import pymysql.cursors +import logging + +logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(message)s", datefmt="%H:%M:%S") +logger = logging.getLogger("UpbitDBInit") + +DB_CFG = dict( + host="192.168.0.141", + port=3306, + user="jae", + password="1234", + database="upbit_quant_db", + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, +) + +DDL_STATEMENTS = [ + # ── 1. 현재 보유 포지션 ────────────────────────────────────────────── + """ + CREATE TABLE IF NOT EXISTS active_trades ( + code VARCHAR(20) NOT NULL PRIMARY KEY COMMENT '마켓코드 (KRW-BTC)', + name VARCHAR(50) COMMENT '종목명', + strategy VARCHAR(50) COMMENT '전략명', + avg_buy_price DECIMAL(20,8) COMMENT '평균 매수가', + current_price DECIMAL(20,8) COMMENT '현재가', + stop_price DECIMAL(20,8) COMMENT '손절가', + target_price DECIMAL(20,8) COMMENT '목표가', + max_price DECIMAL(20,8) COMMENT '보유 중 최고가 (트레일링스탑용)', + atr_entry DECIMAL(20,8) COMMENT '진입 시점 ATR (변동성)', + target_qty DECIMAL(30,10) COMMENT '목표 수량', + current_qty DECIMAL(30,10) COMMENT '현재 수량', + total_invested DECIMAL(20,2) COMMENT '총 투자금액 (원)', + status VARCHAR(20) DEFAULT 'HOLDING' COMMENT '상태 (HOLDING)', + buy_date DATETIME COMMENT '매수 시각', + updated_at DATETIME COMMENT '최종 업데이트', + rsi DECIMAL(8,4) COMMENT '진입 시 RSI', + volume_ratio DECIMAL(8,4) COMMENT '거래량 비율', + tail_length_pct DECIMAL(8,4) COMMENT '꼬리 길이 (%)' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='현재 보유 포지션' + """, + + # ── 2. 매매 기록 ──────────────────────────────────────────────────── + """ + CREATE TABLE IF NOT EXISTS trade_history ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) COMMENT '마켓코드', + name VARCHAR(50) COMMENT '종목명', + strategy VARCHAR(50) COMMENT '전략명', + buy_price DECIMAL(20,8) COMMENT '매수가', + sell_price DECIMAL(20,8) COMMENT '매도가', + qty DECIMAL(30,10) COMMENT '거래 수량', + profit_rate DECIMAL(10,4) COMMENT '수익률 (%)', + realized_pnl DECIMAL(20,2) COMMENT '실현 손익 (원)', + hold_minutes INT COMMENT '보유 시간 (분)', + buy_date DATETIME COMMENT '매수 시각', + sell_date DATETIME COMMENT '매도 시각', + sell_reason VARCHAR(200) COMMENT '매도 사유', + rsi DECIMAL(8,4) COMMENT '진입 시 RSI', + volume_ratio DECIMAL(8,4) COMMENT '거래량 비율', + tail_length_pct DECIMAL(8,4) COMMENT '꼬리 길이 (%)', + INDEX idx_sell_date (sell_date), + INDEX idx_code (code), + INDEX idx_strategy (strategy) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='매매 기록' + """, + + # ── 3. 전략 설정값 (Upbit 전용) ────────────────────────────────────── + """ + CREATE TABLE IF NOT EXISTS env_config ( + id INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME DEFAULT NOW() COMMENT '생성 시각', + -- 업비트 API 키 + UPBIT_ACCESS_KEY VARCHAR(200) DEFAULT '' COMMENT '업비트 Access Key', + UPBIT_SECRET_KEY VARCHAR(200) DEFAULT '' COMMENT '업비트 Secret Key', + -- 알림 (Mattermost) + MM_SERVER_URL VARCHAR(200) DEFAULT '' COMMENT 'MM 서버 URL', + MM_BOT_TOKEN_ VARCHAR(200) DEFAULT '' COMMENT 'MM 봇 토큰', + MATTERMOST_CHANNEL VARCHAR(100) DEFAULT 'upbit' COMMENT 'MM 채널명', + -- 포지션 관리 + MAX_STOCKS VARCHAR(20) DEFAULT '5' COMMENT '최대 보유 코인 수', + SLOT_MONEY_DEFAULT VARCHAR(20) DEFAULT '100000' COMMENT '코인당 투자금액 (원)', + -- 손익 기준 + STOP_LOSS_PCT VARCHAR(20) DEFAULT '-0.02' COMMENT '손절 비율 (음수, 예:-0.02)', + TAKE_PROFIT_PCT VARCHAR(20) DEFAULT '0.05' COMMENT '익절 비율 (예:0.05)', + MAX_LOSS_PER_TRADE_KRW VARCHAR(20) DEFAULT '50000' COMMENT '거래당 최대 원화 손실 한도', + -- 어깨 매도 (수익 보존) + SHOULDER_CUT_PCT VARCHAR(20) DEFAULT '0.03' COMMENT '어깨매도: 고점 대비 하락률', + SHOULDER_MIN_HIGH_PCT VARCHAR(20) DEFAULT '0.01' COMMENT '어깨매도: 발동 최소 이익률', + SHOULDER_MIN_NET_PCT VARCHAR(20) DEFAULT '0.001' COMMENT '어깨매도: 수수료 반영 최소 이익', + -- ATR 스캘핑 엑시트 + SCALP_ATR_UP_MULT VARCHAR(20) DEFAULT '1.0' COMMENT 'ATR 스캘핑: 상승 배수', + SCALP_ATR_DOWN_MULT VARCHAR(20) DEFAULT '0.2' COMMENT 'ATR 스캘핑: 하락 배수', + SCALP_ATR_DROP_MULT VARCHAR(20) DEFAULT '1.0' COMMENT 'ATR 스캘핑: 낙폭 배수', + -- ATR 손절/목표가 배수 + STOP_ATR_MULTIPLIER_TAIL VARCHAR(20) DEFAULT '2.5' COMMENT '손절선: 진입가 - ATR * 배수', + TARGET_ATR_MULTIPLIER_TAIL VARCHAR(20) DEFAULT '7.0' COMMENT '목표가: 진입가 + ATR * 배수', + -- 스캔 조건 + MIN_DROP_RATE VARCHAR(20) DEFAULT '0.03' COMMENT '매수 스캔: 최소 낙폭 (예:0.03)', + MIN_RECOVERY_RATIO VARCHAR(20) DEFAULT '0.30' COMMENT '매수 스캔: 최소 회복률', + MAX_RECOVERY_RATIO VARCHAR(20) DEFAULT '0.80' COMMENT '매수 스캔: 최대 회복률', + HIGH_PRICE_CHASE_THRESHOLD VARCHAR(20) DEFAULT '0.96' COMMENT '고점 추격 방지 임계값', + RSI_OVERHEAT_THRESHOLD VARCHAR(20) DEFAULT '78.0' COMMENT 'RSI 과열 임계값', + -- 꼬리봉 조건 + TAIL_RATIO_MIN VARCHAR(20) DEFAULT '1.5' COMMENT '꼬리/몸통 최소 비율', + TAIL_PCT_MIN VARCHAR(20) DEFAULT '0.003' COMMENT '꼬리 최소 % (예:0.003)', + TAIL_SCORE_BASE VARCHAR(20) DEFAULT '5.0' COMMENT '꼬리 기본 점수', + TAIL_SCORE_RATIO_MULT VARCHAR(20) DEFAULT '2.0' COMMENT '꼬리 비율 점수 가중치', + -- 기타 매매 파라미터 + REENTRY_COOLDOWN_SEC VARCHAR(20) DEFAULT '300' COMMENT '재진입 쿨다운 (초)', + ROUND_TRIP_COST_PCT VARCHAR(20) DEFAULT '0.001' COMMENT '왕복 수수료율 (업비트 0.05%×2)', + MIN_HOLD_AFTER_BUY_SEC VARCHAR(20) DEFAULT '10.0' COMMENT '매수 후 최소 보유 시간 (초)', + -- 스캔 주기 + UPBIT_SCAN_INTERVAL_SEC VARCHAR(20) DEFAULT '60' COMMENT '매수 스캔 주기 (초)', + UPBIT_BUY_TOP_N VARCHAR(20) DEFAULT '2' COMMENT '스캔 후 상위 N개 매수', + UPBIT_CANDLE_UNIT VARCHAR(20) DEFAULT '3' COMMENT '스캔용 분봉 단위 (3/5/15/60)' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='전략 설정값 (업비트 전용)' + """, + + # ── 4. Key-Value 저장소 (API 키 등) ────────────────────────────────── + """ + CREATE TABLE IF NOT EXISTS kv_store ( + k VARCHAR(100) NOT NULL PRIMARY KEY COMMENT '키', + v TEXT COMMENT '값' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Key-Value 저장소' + """, + + # ── 5. 스캔 후보 목록 ─────────────────────────────────────────────── + """ + CREATE TABLE IF NOT EXISTS target_candidates ( + code VARCHAR(20) NOT NULL PRIMARY KEY COMMENT '마켓코드', + name VARCHAR(50) COMMENT '코인명', + score DECIMAL(10,4) COMMENT '후보 점수', + price DECIMAL(20,8) COMMENT '스캔 당시 가격', + scan_time DATETIME COMMENT '스캔 시각', + updated_at DATETIME COMMENT '최종 업데이트' + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='스캔 후보 목록' + """, + + # ── 6. 업비트 분봉 데이터 (백테스트용) ────────────────────────────── + """ + CREATE TABLE IF NOT EXISTS upbit_candles ( + id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(20) NOT NULL COMMENT '마켓코드 (KRW-BTC)', + candle_time VARCHAR(12) NOT NULL COMMENT '봉 시작시각 YYYYMMDDHHMI', + timeframe SMALLINT NOT NULL DEFAULT 3 COMMENT '봉 단위 (3=3분, 60=60분봉)', + open_price DECIMAL(20,8) COMMENT '시가', + high_price DECIMAL(20,8) COMMENT '고가', + low_price DECIMAL(20,8) COMMENT '저가', + close_price DECIMAL(20,8) COMMENT '종가', + volume DECIMAL(30,8) COMMENT '체결량', + is_confirmed TINYINT DEFAULT 1 COMMENT '완성된 봉 여부', + UNIQUE KEY uk_code_time_tf (code, candle_time, timeframe), + INDEX idx_code_tf (code, timeframe), + INDEX idx_time (candle_time) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='업비트 분봉 OHLCV (백테스트용)' + """, +] + +# env_config 초기 기본값 INSERT (이미 row가 있으면 스킵) +ENV_CONFIG_DEFAULT_INSERT = """ + INSERT IGNORE INTO env_config (id, created_at) VALUES (1, NOW()) +""" + + +def run_init(): + logger.info("📦 upbit_quant_db 테이블 초기화 시작...") + try: + conn = pymysql.connect(**DB_CFG) + except Exception as e: + logger.error(f"❌ DB 연결 실패: {e}") + sys.exit(1) + + try: + with conn.cursor() as cur: + for sql in DDL_STATEMENTS: + table_name = sql.strip().split("TABLE IF NOT EXISTS")[1].split("(")[0].strip() + cur.execute(sql) + logger.info(f" ✅ 테이블 생성/확인: {table_name}") + + # env_config 초기 row 삽입 (최초 1회) + cur.execute("SELECT COUNT(*) as cnt FROM env_config") + if cur.fetchone()["cnt"] == 0: + cur.execute(ENV_CONFIG_DEFAULT_INSERT) + logger.info(" ✅ env_config 초기값 row 삽입 완료") + else: + logger.info(" ℹ️ env_config row 이미 존재 — 스킵") + + # kv_store 기본 키 삽입 + kv_defaults = [ + ("UPBIT_ACCESS_KEY", ""), + ("UPBIT_SECRET_KEY", ""), + ("UPBIT_SCAN_INTERVAL_SEC", "60"), + ("UPBIT_BUY_TOP_N", "2"), + ] + for k, v in kv_defaults: + cur.execute( + "INSERT IGNORE INTO kv_store (k, v) VALUES (%s, %s)", (k, v) + ) + logger.info(" ✅ kv_store 기본 키 삽입 완료") + + conn.commit() + logger.info("🎉 upbit_quant_db 초기화 완료!") + + except Exception as e: + logger.error(f"❌ 초기화 중 오류: {e}") + import traceback; traceback.print_exc() + sys.exit(1) + finally: + conn.close() + + +if __name__ == "__main__": + run_init() diff --git a/upbit_short_ver1.py b/upbit_short_ver1.py new file mode 100644 index 0000000..eae6eae --- /dev/null +++ b/upbit_short_ver1.py @@ -0,0 +1,1380 @@ +""" +Upbit Short Trading Bot Ver1 - 단타용 Upbit API 트레이딩 시스템 +- Upbit Open API 사용 (wss://api.upbit.com/websocket/v1) +- 개미털기(눌림목) 전략 기반 단타 매매 +- WebSocket 실시간 체결가 기반 (REST는 캔들 스캔/주문확인에만) +- 체결 기준 포지션 관리 (주문 UUID → state=='done' 폴링 후 반영) +- quant_bot.db 연동 (active_trades, trade_history, env_config, kv_store) +- kis_short_ver2.py 의 단타 전략을 Upbit API 로 변환 +""" + +import json +import time +import uuid +import random +import hashlib +import logging +import sqlite3 +import datetime +import threading +from datetime import datetime as dt +from pathlib import Path +from typing import Dict, List, Optional +from urllib.parse import urlencode + +import pandas as pd +import requests + +# websocket-client 라이브러리 (pip install websocket-client) +try: + import websocket + _WS_AVAILABLE = True +except ImportError: + _WS_AVAILABLE = False + logging.warning("⚠️ websocket-client 미설치 → pip install websocket-client") + +# ============================================================ +# 로깅 설정 +# ============================================================ +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO, +) +logger = logging.getLogger("UpbitShortBot") + +LOG_RED = "\033[91m" # 탈락 +LOG_YELLOW = "\033[93m" # 경고 +LOG_GREEN = "\033[92m" # 통과 +LOG_CYAN = "\033[96m" # 강조 +LOG_RESET = "\033[0m" + +SCRIPT_DIR = Path(__file__).resolve().parent + +# ============================================================ +# DB 모듈 (quant_bot.db 연동) +# ============================================================ + +class UpbitDB: + """ + quant_bot.db 연동 클래스 (기존 스키마 100% 존중) + - env_config : 전략 설정값 읽기 + - kv_store : Upbit API 키 저장/읽기 + - active_trades : 현재 보유 포지션 (재시작 복원용) + - trade_history : 매매 기록 (손익 통계) + - target_candidates: 스캔 후보 목록 + """ + + def __init__(self, db_path: str): + self.db_path = db_path + self._lock = threading.Lock() + self._init_upbit_keys() + + def _conn(self): + """SQLite 연결 (멀티스레드 안전, WAL 모드)""" + conn = sqlite3.connect(self.db_path, check_same_thread=False, timeout=15) + conn.execute("PRAGMA journal_mode=WAL") + return conn + + def _init_upbit_keys(self): + """kv_store 에 Upbit API 키 슬롯이 없으면 빈값으로 초기화""" + with self._lock: + with self._conn() as conn: + conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_ACCESS_KEY', '')") + conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_SECRET_KEY', '')") + conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_SCAN_INTERVAL_SEC', '60')") + conn.execute("INSERT OR IGNORE INTO kv_store (k, v) VALUES ('UPBIT_BUY_TOP_N', '2')") + conn.commit() + + # ---- env_config 읽기 ---- + + def get_latest_env(self) -> dict: + """env_config 최신 row → {컬럼명: 값} 딕셔너리""" + with self._lock: + with self._conn() as conn: + conn.row_factory = sqlite3.Row + cur = conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1") + row = cur.fetchone() + return dict(row) if row else {} + + # ---- kv_store ---- + + def get_kv(self, key: str, default: str = "") -> str: + with self._lock: + with self._conn() as conn: + cur = conn.execute("SELECT v FROM kv_store WHERE k=?", (key,)) + row = cur.fetchone() + return row[0] if row and row[0] else default + + def set_kv(self, key: str, value: str): + with self._lock: + with self._conn() as conn: + conn.execute("INSERT OR REPLACE INTO kv_store (k, v) VALUES (?, ?)", (key, value)) + conn.commit() + + # ---- active_trades ---- + + def load_active_trades(self) -> List[dict]: + """재시작 시 보유 포지션 복원 (HOLDING 상태만)""" + with self._lock: + with self._conn() as conn: + conn.row_factory = sqlite3.Row + cur = conn.execute("SELECT * FROM active_trades WHERE status='HOLDING'") + return [dict(r) for r in cur.fetchall()] + + def upsert_trade(self, trade: dict): + """ + 매수 체결 즉시 active_trades 에 원자적 저장 + (재시작 시 포지션 복원 가능하도록) + """ + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + with self._conn() as conn: + conn.execute(""" + INSERT OR REPLACE INTO active_trades ( + code, name, strategy, + avg_buy_price, current_price, + stop_price, target_price, max_price, atr_entry, + target_qty, current_qty, total_invested, + status, buy_date, updated_at, + rsi, volume_ratio, tail_length_pct + ) VALUES ( + :code, :name, :strategy, + :avg_buy_price, :current_price, + :stop_price, :target_price, :max_price, :atr_entry, + :target_qty, :current_qty, :total_invested, + :status, :buy_date, :updated_at, + :rsi, :volume_ratio, :tail_length_pct + ) + """, {**trade, 'updated_at': now}) + conn.commit() + + def update_trade_max_price(self, code: str, current_price: float, max_price: float): + """매도 체크 루프에서 고점 갱신 시 즉시 저장""" + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + with self._conn() as conn: + conn.execute( + "UPDATE active_trades SET current_price=?, max_price=?, updated_at=? WHERE code=?", + (current_price, max_price, now, code) + ) + conn.commit() + + def close_trade(self, code: str, sell_price: float, sell_reason: str): + """ + 매도 체결 즉시: active_trades 제거 + trade_history 추가 (원자적) + 이벤트 발생 즉시 저장 → 재시작 시 중복 매도 방지 + """ + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + with self._conn() as conn: + conn.row_factory = sqlite3.Row + cur = conn.execute("SELECT * FROM active_trades WHERE code=?", (code,)) + row = cur.fetchone() + if not row: + return + row = dict(row) + + buy_price = row['avg_buy_price'] + qty = row['current_qty'] + buy_date = row['buy_date'] + profit_rate = (sell_price - buy_price) / buy_price * 100 if buy_price else 0 + realized_pnl = (sell_price - buy_price) * qty + + hold_minutes = 0 + try: + hold_minutes = int((dt.now() - dt.strptime(buy_date, "%Y-%m-%d %H:%M:%S")).total_seconds() / 60) + except Exception: + pass + + conn.execute(""" + INSERT INTO trade_history ( + code, name, strategy, + buy_price, sell_price, qty, + profit_rate, realized_pnl, hold_minutes, + buy_date, sell_date, sell_reason, + rsi, volume_ratio, tail_length_pct + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + code, row['name'], row.get('strategy', ''), + buy_price, sell_price, qty, + profit_rate, realized_pnl, hold_minutes, + buy_date, now, sell_reason, + row.get('rsi'), row.get('volume_ratio'), row.get('tail_length_pct'), + )) + conn.execute("DELETE FROM active_trades WHERE code=?", (code,)) + conn.commit() + + # ---- target_candidates ---- + + def upsert_candidate(self, code: str, name: str, score: float, price: float): + """스캔 후보 이벤트 즉시 저장""" + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + with self._conn() as conn: + conn.execute(""" + INSERT OR REPLACE INTO target_candidates (code, name, score, price, scan_time, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + """, (code, name, score, price, now, now)) + conn.commit() + + def clear_old_candidates(self, hours: int = 2): + """오래된 후보 정리""" + cutoff = (dt.now() - datetime.timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + with self._lock: + with self._conn() as conn: + conn.execute("DELETE FROM target_candidates WHERE updated_at < ?", (cutoff,)) + conn.commit() + + # ---- 반켈리 승률 계산 ---- + + def calculate_half_kelly(self, lookback: int = 50) -> float: + """최근 N건 거래 기준 Half-Kelly 비율 (5~50% 클리핑)""" + with self._lock: + with self._conn() as conn: + cur = conn.execute( + "SELECT profit_rate FROM trade_history ORDER BY id DESC LIMIT ?", (lookback,) + ) + rows = cur.fetchall() + if not rows: + return 0.25 # 기본값 + profits = [r[0] for r in rows] + wins = [p for p in profits if p > 0] + if not wins: + return 0.05 + win_rate = len(wins) / len(profits) + avg_win = sum(wins) / len(wins) + losses = [abs(p) for p in profits if p < 0] + avg_loss = sum(losses) / len(losses) if losses else 1.0 + b = avg_win / avg_loss if avg_loss else 1.0 + kelly = (b * win_rate - (1 - win_rate)) / b if b else 0 + return max(0.05, min(0.5, kelly / 2)) + + +# ============================================================ +# 전역 DB & env 헬퍼 +# ============================================================ + +_db: Optional[UpbitDB] = None # run() 에서 초기화 + +def _env_raw(key: str, default: str = "") -> str: + env = _db.get_latest_env() if _db else {} + val = env.get(key) + if not val: + val = _db.get_kv(key) if _db else "" + if not val: + val = default + if isinstance(val, str) and "#" in val: + val = val.split("#")[0].strip() + return str(val) if val is not None else str(default) + +def get_env_float(key: str, default: float) -> float: + try: + return float(_env_raw(key, str(default))) + except (ValueError, TypeError): + return default + +def get_env_int(key: str, default: int) -> int: + try: + return int(float(_env_raw(key, str(default)))) + except (ValueError, TypeError): + return default + +def get_env_bool(key: str, default: bool = False) -> bool: + return _env_raw(key, str(default)).lower() in ("true", "1", "yes") + +def get_env_str(key: str, default: str = "") -> str: + return _env_raw(key, default) + + +# ============================================================ +# Mattermost 알림 +# ============================================================ + +def send_mm(msg: str): + """Mattermost 알림 전송 (실패 시 로그만 남기고 봇 계속 실행)""" + try: + server = get_env_str("MM_SERVER_URL", "") + token = get_env_str("MM_BOT_TOKEN_", "") + channel = get_env_str("MATTERMOST_CHANNEL", "upbit") + if not server or not token: + return + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + r = requests.get(f"{server}/api/v4/channels/name/{channel}", headers=headers, timeout=5) + if r.status_code != 200: + return + channel_id = r.json().get("id", "") + requests.post( + f"{server}/api/v4/posts", + headers=headers, + json={"channel_id": channel_id, "message": msg}, + timeout=5, + ) + except Exception as e: + logger.debug(f"MM 발송 실패: {e}") + + +# ============================================================ +# WebSocket 실시간 체결가 캐시 +# ============================================================ + +class UpbitWSPriceCache: + """ + Upbit WebSocket 실시간 체결가 캐시 + - URL : wss://api.upbit.com/websocket/v1 + - 타입 : trade (체결 틱) → SIMPLE 포맷 + - 캐시 : {market: {price, volume, timestamp}} + - 자동 재연결: 끊기면 5초 후 재시도 + - 구독 갱신: subscribe()/unsubscribe() 호출 즉시 서버에 반영 + + [SIMPLE 포맷 주요 필드] + cd : 마켓코드 (KRW-BTC 등) + tp : 체결가 (trade price) + tv : 체결량 (trade volume) + tms : 체결 타임스탬프 (ms) + """ + + WS_URL = "wss://api.upbit.com/websocket/v1" + + def __init__(self): + self._cache: Dict[str, dict] = {} # {market: {price, volume, timestamp}} + self._lock = threading.Lock() + self._codes = set() # 구독 중인 마켓 코드 + self._ws: Optional["websocket.WebSocketApp"] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self.is_active = False + + # ---- 구독 관리 ---- + + def subscribe(self, *markets: str): + """마켓 추가 구독 → 즉시 서버에 재구독 메시지 전송""" + with self._lock: + before = len(self._codes) + self._codes.update(markets) + changed = len(self._codes) != before + if changed and self._ws and self.is_active: + self._send_subscribe() + + def unsubscribe(self, *markets: str): + """마켓 구독 해제 → 즉시 서버에 재구독 메시지 전송""" + with self._lock: + self._codes.difference_update(markets) + # 캐시에서도 제거 + for m in markets: + self._cache.pop(m, None) + if self._ws and self.is_active: + self._send_subscribe() + + def _send_subscribe(self): + """현재 구독 목록으로 WebSocket 구독 메시지 전송""" + with self._lock: + codes = list(self._codes) + if not codes or not self._ws: + return + payload = json.dumps([ + {"ticket": str(uuid.uuid4())}, + {"type": "trade", "codes": codes, "isOnlyRealtime": True}, + {"format": "SIMPLE"}, + ]) + try: + self._ws.send(payload) + logger.info(f"[WS] 구독 갱신: {len(codes)}개 마켓") + except Exception as e: + logger.warning(f"[WS] 구독 전송 실패: {e}") + + # ---- 가격 조회 ---- + + def get_price(self, market: str, max_age_sec: float = 8.0) -> Optional[dict]: + """ + 캐시에서 현재 체결가 반환 + - max_age_sec 이내 수신분만 유효 (오래된 건 REST fallback 유도) + - 미구독/만료면 None 반환 + """ + with self._lock: + data = self._cache.get(market) + if not data: + return None + if time.time() - data['timestamp'] > max_age_sec: + return None + return data + + # ---- WebSocket 콜백 ---- + + def _on_open(self, ws): + self.is_active = True + logger.info("[WS] Upbit WebSocket 연결 완료") + self._send_subscribe() + + def _on_message(self, ws, raw): + """ + 체결 틱 수신 → price_cache 갱신 + - SIMPLE 포맷: {"ty":"trade","cd":"KRW-BTC","tp":50000000.0,...} + """ + try: + # Upbit 은 바이너리(UTF-8)로 올 때도 있음 + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + data = json.loads(raw) + market = data.get("cd") + price = data.get("tp") # 체결가 + volume = data.get("tv") # 체결량 + if market and price: + with self._lock: + self._cache[market] = { + "price": float(price), + "volume": float(volume) if volume else 0.0, + "timestamp": time.time(), + } + except Exception as e: + logger.debug(f"[WS] 메시지 파싱 오류: {e}") + + def _on_error(self, ws, error): + logger.warning(f"[WS] 오류: {error}") + self.is_active = False + + def _on_close(self, ws, code, msg): + logger.warning(f"[WS] 연결 종료 (code={code})") + self.is_active = False + + # ---- 시작/종료 ---- + + def start(self, initial_markets: list = None): + """WebSocket 백그라운드 스레드 시작""" + if not _WS_AVAILABLE: + logger.error("websocket-client 미설치 → WebSocket 비활성화") + return + if initial_markets: + with self._lock: + self._codes.update(initial_markets) + self._stop.clear() + self._thread = threading.Thread(target=self._run_loop, daemon=True, name="UpbitWS") + self._thread.start() + logger.info("[WS] WebSocket 스레드 시작") + + def stop(self): + """WebSocket 종료""" + self._stop.set() + if self._ws: + try: + self._ws.close() + except Exception: + pass + self.is_active = False + logger.info("[WS] WebSocket 종료") + + def _run_loop(self): + """WebSocket 자동 재연결 루프 (5초 간격)""" + while not self._stop.is_set(): + try: + self._ws = websocket.WebSocketApp( + self.WS_URL, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + ) + # ping/pong 으로 연결 유지 + self._ws.run_forever(ping_interval=30, ping_timeout=10) + except Exception as e: + logger.warning(f"[WS] run_forever 예외: {e}") + finally: + self.is_active = False + if not self._stop.is_set(): + logger.info("[WS] 5초 후 재연결...") + time.sleep(5) + + +# ============================================================ +# Upbit REST API 클라이언트 +# ============================================================ + +class UpbitClient: + """ + Upbit REST API 클라이언트 + - Exchange API (주문/계좌): JWT 인증 필요 + - Quotation API (시세/캔들): 인증 불필요 + - HTTP 429 자동 재시도 (점진적 대기) + + [레이트리밋] + - 주문 API: 분당 200회 + - 시세 API: 분당 600회 + → 요청 간 0.12초 갭 유지 (안전 마진) + """ + + BASE = "https://api.upbit.com/v1" + + def __init__(self, access_key: str, secret_key: str): + self.access_key = access_key + self.secret_key = secret_key + self._last_t = 0.0 + self._last_order_error = None + + def _throttle(self, gap: float = 0.12): + """요청 간격 조절 (레이트리밋 방지)""" + elapsed = time.time() - self._last_t + if elapsed < gap: + time.sleep(gap - elapsed) + self._last_t = time.time() + + # ---- JWT 인증 ---- + + def _make_jwt(self, query_string: str = "") -> str: + try: + import jwt as pyjwt + except ImportError: + raise RuntimeError("PyJWT 필요: pip install PyJWT") + payload = {"access_key": self.access_key, "nonce": str(uuid.uuid4())} + if query_string: + qh = hashlib.sha512(query_string.encode()).hexdigest() + payload["query_hash"] = qh + payload["query_hash_alg"] = "SHA512" + return pyjwt.encode(payload, self.secret_key, algorithm="HS256") + + def _auth_headers(self, qs: str = "") -> dict: + return {"Authorization": f"Bearer {self._make_jwt(qs)}"} + + # ---- 공통 GET / POST ---- + + def _get(self, path: str, params: dict = None, auth: bool = False, retries: int = 5) -> Optional[dict]: + self._throttle() + url = f"{self.BASE}{path}" + qs = urlencode(params, doseq=True) if params else "" + hdrs = self._auth_headers(qs) if auth else {} + for attempt in range(retries): + try: + r = requests.get(url, params=params, headers=hdrs, timeout=10) + if r.status_code == 429: + wait = 1.0 + attempt + logger.warning(f"⏳ REST 429 → {wait:.0f}초 대기 (attempt {attempt+1})") + time.sleep(wait) + continue + if r.status_code == 200: + return r.json() + logger.warning(f"[REST] GET {path} → {r.status_code}: {r.text[:200]}") + return None + except requests.RequestException as e: + wait = (2 ** attempt) + random.uniform(0.3, 1.0) + logger.warning(f"⚠️ 네트워크 오류 ({attempt+1}/{retries}): {e} → {wait:.1f}초") + time.sleep(wait) + return None + + def _post(self, path: str, body: dict, retries: int = 3) -> Optional[dict]: + self._throttle() + url = f"{self.BASE}{path}" + qs = urlencode(body, doseq=True) + hdrs = {**self._auth_headers(qs), "Content-Type": "application/json"} + for attempt in range(retries): + try: + r = requests.post(url, json=body, headers=hdrs, timeout=10) + if r.status_code == 429: + wait = 3.0 + attempt * 2 + logger.warning(f"⏳ REST 429 → {wait:.0f}초 대기 (attempt {attempt+1})") + time.sleep(wait) + continue + data = r.json() + if r.status_code in (200, 201): + return data + self._last_order_error = data + logger.warning(f"[REST] POST {path} → {r.status_code}: {data}") + return None + except requests.RequestException as e: + wait = (2 ** attempt) + random.uniform(0.3, 1.0) + logger.warning(f"⚠️ 네트워크 오류 ({attempt+1}/{retries}): {e}") + time.sleep(wait) + return None + + # ---- 계좌 ---- + + def get_accounts(self) -> list: + return self._get("/accounts", auth=True) or [] + + def get_krw_balance(self) -> float: + """가용 KRW 잔고 반환""" + for acc in self.get_accounts(): + if acc.get("currency") == "KRW": + # balance: 전체 잔고, locked: 주문 중 금액 + return float(acc.get("balance", 0)) + return 0.0 + + # ---- 시세 (Quotation) ---- + + def get_market_list(self) -> list: + return self._get("/market/all") or [] + + def get_ticker(self, markets: list) -> list: + return self._get("/ticker", params={"markets": ",".join(markets)}) or [] + + def get_candles_minutes(self, market: str, unit: int = 3, count: int = 50) -> list: + """ + 분봉 조회 (unit: 1,3,5,10,15,30,60,240) + 응답: 최신 봉이 인덱스 0 (시간 역순) → candles_to_df() 에서 정렬 + """ + return self._get(f"/candles/minutes/{unit}", + params={"market": market, "count": count}) or [] + + def get_candles_days(self, market: str, count: int = 30) -> list: + return self._get("/candles/days", params={"market": market, "count": count}) or [] + + # ---- 주문 ---- + + def order_market_buy(self, market: str, price_krw: float) -> Optional[str]: + """ + 시장가 매수 주문 (금액 기준) + Returns: 주문 UUID → wait_for_fill() 에 전달 + """ + body = { + "market": market, + "side": "bid", + "price": str(int(price_krw)), + "ord_type": "price", # 금액 지정 시장가 매수 + } + result = self._post("/orders", body) + return result.get("uuid") if result else None + + def order_market_sell(self, market: str, volume: float) -> Optional[str]: + """ + 시장가 매도 주문 (수량 기준) + Returns: 주문 UUID → wait_for_fill() 에 전달 + """ + body = { + "market": market, + "side": "ask", + "volume": str(volume), + "ord_type": "market", # 수량 지정 시장가 매도 + } + result = self._post("/orders", body) + return result.get("uuid") if result else None + + def wait_for_fill(self, order_uuid: str, timeout_sec: int = 20, poll: float = 0.5) -> Optional[dict]: + """ + ★ 체결 확인 (폴링) + - state == 'done' 이 될 때까지 REST 폴링 + - 실제 체결가(avg_price) / 체결량(filled_volume) 반환 + - timeout_sec 초 내 미체결 시 None 반환 + + Upbit 주문 상태: + wait : 대기 (미체결) + watch : 예약 (예약주문) + done : 완료 (체결 완료) + cancel : 취소 + """ + deadline = time.time() + timeout_sec + while time.time() < deadline: + data = self._get("/order", params={"uuid": order_uuid}, auth=True) + if not data: + time.sleep(poll) + continue + + state = data.get("state") + + if state == "done": + # trades 배열로 정확한 평균 체결가 계산 + trades = data.get("trades", []) + if trades: + total_vol = sum(float(t["volume"]) for t in trades) + total_cost = sum(float(t["price"]) * float(t["volume"]) for t in trades) + avg_price = total_cost / total_vol if total_vol else 0.0 + paid_fee = sum(float(t.get("funds", 0)) * 0.0005 for t in trades) + else: + # trades 없으면 API 필드로 fallback + avg_price = float(data.get("avg_price") or data.get("price", 0)) + total_vol = float(data.get("executed_volume", 0)) + paid_fee = float(data.get("paid_fee", 0)) + + return { + "avg_price": avg_price, + "filled_volume": total_vol, + "paid_fee": paid_fee, + } + + if state == "cancel": + logger.warning(f"[체결확인] 주문 취소됨: {order_uuid}") + return None + + time.sleep(poll) + + logger.warning(f"[체결확인] 타임아웃 ({timeout_sec}s): {order_uuid}") + return None + + def cancel_order(self, order_uuid: str) -> bool: + hdrs = self._auth_headers(f"uuid={order_uuid}") + try: + r = requests.delete(f"{self.BASE}/order", + params={"uuid": order_uuid}, + headers=hdrs, timeout=10) + return r.status_code == 200 + except Exception: + return False + + +# ============================================================ +# 지표 계산 유틸 +# ============================================================ + +def candles_to_df(candles: list) -> pd.DataFrame: + """ + Upbit 분봉/일봉 리스트 → DataFrame (시간 오름차순) + Upbit 응답은 최신 봉이 인덱스 0 (역순) → sort 필수 + """ + if not candles: + return pd.DataFrame() + df = pd.DataFrame(candles).rename(columns={ + "opening_price": "open", + "high_price": "high", + "low_price": "low", + "trade_price": "close", + "candle_acc_trade_volume": "volume", + "candle_date_time_kst": "datetime", + }) + for col in ("open", "high", "low", "close", "volume"): + if col in df.columns: + df[col] = df[col].astype(float) + if "datetime" in df.columns: + df = df.sort_values("datetime").reset_index(drop=True) + return df + + +def calc_rsi(close: pd.Series, period: int = 14) -> float: + """RSI 계산 (마지막 값 반환). 데이터 부족 시 50 반환""" + if len(close) < period + 1: + return 50.0 + delta = close.diff() + gain = delta.where(delta > 0, 0.0).rolling(period).mean() + loss = (-delta.where(delta < 0, 0.0)).rolling(period).mean() + rs = gain / loss.replace(0, float("nan")) + rsi = 100 - (100 / (1 + rs)) + val = rsi.iloc[-1] + return float(val) if not pd.isna(val) else 50.0 + + +def calc_atr(df: pd.DataFrame, period: int = 14) -> float: + """ATR (Average True Range) 계산. 데이터 부족 시 0 반환""" + if df.empty or len(df) < period + 1: + return 0.0 + h, l, c = df["high"], df["low"], df["close"] + tr = pd.concat([(h - l), (h - c.shift(1)).abs(), (l - c.shift(1)).abs()], axis=1).max(axis=1) + val = tr.rolling(period).mean().iloc[-1] + return float(val) if not pd.isna(val) else 0.0 + + +# ============================================================ +# 단타 트레이더 (메인 클래스) +# ============================================================ + +class UpbitShortTrader: + """ + Upbit 단타 트레이더 (개미털기/눌림목 전략) + + [아키텍처] + ┌─────────────────────────────────────────────┐ + │ WebSocket 스레드 (daemon) │ + │ wss://api.upbit.com/websocket/v1 │ + │ → trade 체결 틱 수신 → price_cache 업데이트 │ + └───────────────────┬─────────────────────────┘ + │ (캐시 읽기, 잠금 없음) + ┌───────────────────▼─────────────────────────┐ + │ 메인 루프 (2초 주기) │ + │ ① 매도 신호 체크 → WS 캐시 우선, REST fallback│ + │ ② 매수 스캔 (60초마다) → REST 3분봉 │ + └───────────────────┬─────────────────────────┘ + │ + ┌───────────────────▼─────────────────────────┐ + │ 주문 실행 → REST POST /orders │ + │ 체결 확인 → REST GET /order (UUID 폴링) │ + │ ★ state=='done' 확인 후에만 포지션 반영 │ + └─────────────────────────────────────────────┘ + """ + + def __init__(self): + global _db + _db = UpbitDB(str(SCRIPT_DIR / "quant_bot.db")) + self.db = _db + + # API 키 (kv_store 우선, env_config fallback) + access_key = self.db.get_kv("UPBIT_ACCESS_KEY") or get_env_str("UPBIT_ACCESS_KEY", "") + secret_key = self.db.get_kv("UPBIT_SECRET_KEY") or get_env_str("UPBIT_SECRET_KEY", "") + if not access_key or not secret_key: + logger.warning("⚠️ Upbit API 키 미설정 → kv_store 에 UPBIT_ACCESS_KEY/UPBIT_SECRET_KEY 저장 필요") + + self.client = UpbitClient(access_key, secret_key) + self.ws_cache = UpbitWSPriceCache() + + # 보유 포지션 메모리 {code: holding_dict} + self.holdings: Dict[str, dict] = {} + + # 최근 매도 쿨다운 {code: timestamp} + self.recently_sold: Dict[str, float] = {} + + # 설정 로드 + self._load_settings() + + # DB 에서 보유 포지션 복원 (재시작 시) + self._restore_holdings() + + def _load_settings(self): + """DB env_config 에서 전략 파라미터 로드 (모두 get_env_* 로 하드코딩 금지)""" + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.02) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05) + self.max_stocks = get_env_int("MAX_STOCKS", 5) + self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03) + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO", 0.30) + self.rsi_overheat = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) + self.tail_ratio_min = get_env_float("TAIL_RATIO_MIN", 1.5) + self.tail_pct_min = get_env_float("TAIL_PCT_MIN", 0.003) + self.shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", 0.03) + self.shoulder_min_high = get_env_float("SHOULDER_MIN_HIGH_PCT", 0.01) + self.scalp_up_mult = get_env_float("SCALP_ATR_UP_MULT", 1.0) + self.scalp_down_mult = get_env_float("SCALP_ATR_DOWN_MULT", 0.2) + self.scalp_drop_mult = get_env_float("SCALP_ATR_DROP_MULT", 1.0) + self.reentry_cooldown = get_env_int("REENTRY_COOLDOWN_SEC", 300) + self.round_trip_cost = get_env_float("ROUND_TRIP_COST_PCT", 0.0005) # Upbit 수수료 0.05% + + logger.info( + f"⚙️ 설정 로드 | 손절={self.stop_loss_pct*100:.1f}% " + f"| 익절={self.take_profit_pct*100:.1f}% " + f"| 최대종목={self.max_stocks}" + ) + + def _restore_holdings(self): + """재시작 시 DB active_trades → 메모리 복원 + WS 재구독""" + rows = self.db.load_active_trades() + for r in rows: + code = r["code"] + self.holdings[code] = { + "name": r["name"], + "buy_price": r["avg_buy_price"], + "qty": r["current_qty"], + "buy_time": r["buy_date"], + "max_price": r.get("max_price") or r["avg_buy_price"], + "stop_price": r.get("stop_price") or r["avg_buy_price"] * (1 + self.stop_loss_pct), + "target_price": r.get("target_price") or r["avg_buy_price"] * (1 + self.take_profit_pct), + "atr_entry": r.get("atr_entry") or 0.0, + "rsi": r.get("rsi") or 50.0, + "volume_ratio": r.get("volume_ratio") or 1.0, + "tail_length_pct": r.get("tail_length_pct") or 0.0, + } + if rows: + logger.info(f"♻️ 보유 포지션 복원: {list(self.holdings.keys())}") + + # -------------------------------------------------------- + # 매수 스캔 (REST 캔들 기반, 60초 주기) + # -------------------------------------------------------- + + def scan_buy_candidates(self, market_list: list) -> list: + """ + 전체 KRW 마켓 순회 → 개미털기(눌림목) 조건 필터링 + + [조건 순서] + 1. 낙폭: 시가 대비 저가 낙폭 ≥ MIN_DROP_RATE + 2. 회복률: 저가→현재가 회복률 [MIN_RECOVERY_RATIO ~ MAX_RECOVERY_RATIO] + 3. 꼬리봉: 밑꼬리/몸통 비율 ≥ TAIL_RATIO_MIN, 꼬리% ≥ TAIL_PCT_MIN + 4. 고점 추격 방지: 현재가 < 당일고점 × HIGH_PRICE_CHASE_THRESHOLD + 5. RSI: < RSI_OVERHEAT_THRESHOLD + 6. 점수 계산 후 내림차순 정렬 + """ + candidates = [] + + for market in market_list: + if market in self.holdings: + continue # 이미 보유 중 + + # 재진입 쿨다운 + if market in self.recently_sold: + if time.time() - self.recently_sold[market] < self.reentry_cooldown: + continue + + try: + # 3분봉 50개 (약 2.5시간) + candles = self.client.get_candles_minutes(market, unit=3, count=50) + if not candles or len(candles) < 20: + continue + + df = candles_to_df(candles) + current_price = df["close"].iloc[-1] + day_open = df["open"].iloc[0] + day_high = df["high"].max() + # 0인 저가(비정상 봉) 제외 + valid_lows = df["low"][df["low"] > 0] + day_low = float(valid_lows.min()) if not valid_lows.empty else df["low"].min() + day_range = day_high - day_low + + # [1] 낙폭 필터 + drop_rate = (day_open - day_low) / day_open if day_open > 0 else 0 + if drop_rate < self.min_drop_rate: + logger.info(f"{LOG_YELLOW}[탈락-낙폭] {market}: {drop_rate*100:.1f}% < {self.min_drop_rate*100:.1f}%{LOG_RESET}") + continue + + # [2] 회복률 필터 + recovery = (current_price - day_low) / day_range if day_range > 0 else 0 + max_rec = get_env_float("MAX_RECOVERY_RATIO", 0.8) + if not (self.min_recovery_ratio <= recovery <= max_rec): + logger.info(f"{LOG_YELLOW}[탈락-회복] {market}: {recovery*100:.1f}%{LOG_RESET}") + continue + + # [3] 망치봉 꼬리 계산 (마지막 봉 기준) + last = df.iloc[-1] + c_open, c_close = last["open"], last["close"] + c_low, c_high = last["low"], last["high"] + body_top = max(c_open, c_close) + body_bottom = min(c_open, c_close) + body_len = max(body_top - body_bottom, 1.0) + tail_len = max(body_bottom - c_low, 0.0) + tail_ratio = tail_len / body_len + tail_pct = tail_len / c_low if c_low > 0 else 0 + + # 마지막 봉에 꼬리가 없으면 최근 5봉 재탐색 (장외 도지 대응) + if tail_len <= 0 and len(df) >= 2: + for idx in range(len(df) - 2, max(-1, len(df) - 6), -1): + c = df.iloc[idx] + bt = max(c["open"], c["close"]) + bb = min(c["open"], c["close"]) + bl = max(bt - bb, 1.0) + tl = max(bb - c["low"], 0.0) + if tl > 0: + tail_ratio = tl / bl + tail_pct = tl / c["low"] if c["low"] > 0 else 0 + tail_len = tl + break + + if tail_ratio < self.tail_ratio_min or tail_pct < self.tail_pct_min: + logger.info(f"{LOG_YELLOW}[탈락-꼬리] {market}: 비율={tail_ratio:.2f}, pct={tail_pct*100:.2f}%{LOG_RESET}") + continue + + # [4] 고점 추격 방지 + hc_thr = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + if current_price >= day_high * hc_thr: + logger.info(f"{LOG_YELLOW}[탈락-고점추격] {market}{LOG_RESET}") + continue + + # [5] RSI 과열 방지 + rsi = calc_rsi(df["close"]) + if rsi >= self.rsi_overheat: + logger.info(f"{LOG_YELLOW}[탈락-RSI] {market}: {rsi:.1f}{LOG_RESET}") + continue + + # [6] 거래량 비율 + avg_vol = df["volume"].mean() + last_vol = df["volume"].iloc[-1] + volume_ratio = last_vol / avg_vol if avg_vol > 0 else 1.0 + + # [7] ATR / 점수 + atr = calc_atr(df) + score = get_env_float("TAIL_SCORE_BASE", 5.0) + \ + tail_ratio * get_env_float("TAIL_SCORE_RATIO_MULT", 2.0) + + candidates.append({ + "code": market, + "name": market.replace("KRW-", ""), + "price": current_price, + "score": score, + "atr": atr, + "rsi": rsi, + "volume_ratio": volume_ratio, + "tail_ratio": tail_ratio, + "tail_pct": tail_pct, + "recovery": recovery, + "drop_rate": drop_rate, + }) + logger.info( + f"{LOG_GREEN}🎯 [후보] {market} | 가격:{current_price:,.0f} " + f"| 꼬리:{tail_ratio:.1f}x | RSI:{rsi:.1f} | 점수:{score:.1f}{LOG_RESET}" + ) + # DB 후보 즉시 저장 + self.db.upsert_candidate(market, market.replace("KRW-", ""), score, current_price) + + except Exception as e: + logger.debug(f"[스캔] {market} 오류: {e}") + + # 마켓 간 레이트리밋 방지 + time.sleep(random.uniform(0.12, 0.20)) + + candidates.sort(key=lambda x: x["score"], reverse=True) + return candidates + + # -------------------------------------------------------- + # 매도 신호 체크 (WebSocket 캐시 기반, 2초 주기) + # -------------------------------------------------------- + + def check_sell_signals(self) -> list: + """ + 보유 포지션 매도 신호 체크 + - 가격 소스: WebSocket 캐시 우선 (max_age=8초), 만료 시 REST fallback + - 주문이 아닌 신호만 생성 → execute_sell() 에서 실제 주문/체결 확인 + + [매도 우선순위] + 1. 어깨매도 : 고점 대비 SHOULDER_CUT_PCT 이상 하락 (수익 보존) + 2. ATR 스캘핑: 고점→본절 복귀 또는 고점→ATR 하락 + 3. 목표가 달성 + 4. 손절 (% 또는 원화 기준) + """ + if not self.holdings: + return [] + + signals = [] + min_hold = get_env_float("MIN_HOLD_AFTER_BUY_SEC", 10.0) + + for code, h in list(self.holdings.items()): + try: + # 최소 보유 시간 체크 (체결 직후 잔고 반영 딜레이 대응) + if h.get("buy_time"): + buy_dt = dt.strptime(h["buy_time"], "%Y-%m-%d %H:%M:%S") + if (dt.now() - buy_dt).total_seconds() < min_hold: + continue + + # ★ 현재가: WebSocket 캐시 우선 → REST fallback + ws_data = self.ws_cache.get_price(code, max_age_sec=8.0) + if ws_data: + current_price = ws_data["price"] + _src = "WS" + else: + # WebSocket 미연결 or 데이터 만료 → REST + tickers = self.client.get_ticker([code]) + if not tickers: + continue + current_price = float(tickers[0]["trade_price"]) + _src = "REST" + + if current_price <= 0: + continue + + buy_price = h["buy_price"] + qty = h["qty"] + max_price = h.get("max_price", buy_price) + + # 고점 갱신 (즉시 DB 저장) + if current_price > max_price: + max_price = current_price + self.holdings[code]["max_price"] = max_price + self.db.update_trade_max_price(code, current_price, max_price) + + profit_pct = (current_price - buy_price) / buy_price if buy_price else 0 + profit_krw = (current_price - buy_price) * qty + + hours_held = 0.0 + if h.get("buy_time"): + buy_dt = dt.strptime(h["buy_time"], "%Y-%m-%d %H:%M:%S") + hours_held = (dt.now() - buy_dt).total_seconds() / 3600 + + atr = h.get("atr_entry", 0) or buy_price * 0.01 + stop_price = h.get("stop_price", buy_price * (1 + self.stop_loss_pct)) + target_price = h.get("target_price", buy_price * (1 + self.take_profit_pct)) + + sell_reason = None + + # [1] 어깨매도: 고점이 진입가 대비 충분히 올랐을 때만 적용 + drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0 + high_above_entry = (max_price - buy_price) / buy_price if buy_price > 0 else 0 + + # 수수료 반영 손익분기 하한 자동 계산 + min_high_required = (1 + self.round_trip_cost + get_env_float("SHOULDER_MIN_NET_PCT", 0.001)) / \ + (1 - self.shoulder_cut_pct) - 1 if (1 - self.shoulder_cut_pct) > 0 else 0.01 + eff_shoulder_min = max(self.shoulder_min_high, min_high_required) + + if drop_from_high >= self.shoulder_cut_pct and high_above_entry >= eff_shoulder_min: + sell_reason = f"어깨매도(고점-{drop_from_high*100:.1f}%)" + + # [2] ATR 스캘핑: 고점 달성 후 본절 근처로 내려오면 매도 + if not sell_reason and atr > 0: + if max_price >= buy_price + atr * self.scalp_up_mult and \ + current_price <= buy_price + atr * self.scalp_down_mult: + sell_reason = "스캘핑_본절사수" + if not sell_reason and \ + current_price < (max_price - atr * self.scalp_drop_mult) and profit_pct > 0: + sell_reason = "스캘핑_익절보존" + + # [3] 목표가 달성 + if not sell_reason and current_price >= target_price: + sell_reason = f"목표달성({profit_pct*100:+.2f}%)" + + # [4] 손절 (% 기준) + if not sell_reason and (current_price <= stop_price or profit_pct <= self.stop_loss_pct): + sell_reason = f"손절({profit_pct*100:.2f}%)" + + # [5] 원화 손실 한도 (금액 기준 손절) + max_loss_krw = get_env_float("MAX_LOSS_PER_TRADE_KRW", 50000) + if not sell_reason and profit_krw <= -max_loss_krw: + sell_reason = f"금액손절({profit_krw:+,.0f}원)" + + if sell_reason: + signals.append({ + "code": code, + "name": h["name"], + "reason": sell_reason, + "profit_pct": profit_pct, + "qty": qty, + "price": current_price, + "_src": _src, + }) + logger.info( + f"💸 [매도신호/{_src}] {h['name']}({code}): " + f"{sell_reason} | {profit_pct*100:+.2f}%" + ) + + except Exception as e: + logger.error(f"[매도체크] {code} 오류: {e}") + + return signals + + # -------------------------------------------------------- + # 매수 실행 (★ 체결 기준) + # -------------------------------------------------------- + + def execute_buy(self, candidate: dict) -> bool: + """ + 매수 주문 → 체결 확인 → holdings & DB 반영 + + [체결 기준 플로우] + 1. order_market_buy() → UUID 수령 + 2. wait_for_fill(UUID) → state=='done' 폴링 + 3. 실제 avg_price / filled_volume 으로 포지션 등록 + 4. DB active_trades 즉시 저장 (재시작 복원용) + 5. WS 구독 추가 → 이후 매도 체크는 WS 기반 + """ + code = candidate["code"] + name = candidate["name"] + price = candidate["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 + + cash = self.client.get_krw_balance() + slot = get_env_float("SLOT_MONEY_DEFAULT", 100000) + if cash < slot * 1.05: + logger.warning(f"⚠️ [{name}] 잔고 부족: {cash:,.0f}원 < {slot*1.05:,.0f}원") + return False + + amount = min(slot, cash * 0.90) + + # 주문 + logger.info(f"🟡 [매수주문] {name}({code}) {amount:,.0f}원 시장가") + order_uuid = self.client.order_market_buy(code, amount) + if not order_uuid: + logger.warning(f"❌ [{name}] 매수 주문 실패: {self.client._last_order_error}") + return False + + # ★ 체결 확인 (주문 UUID로 REST 폴링) + fill = self.client.wait_for_fill(order_uuid, timeout_sec=20) + if not fill or fill["filled_volume"] <= 0: + logger.warning(f"❌ [{name}] 매수 체결 미확인 (UUID={order_uuid})") + # 미체결이면 주문 취소 시도 + self.client.cancel_order(order_uuid) + return False + + filled_price = fill["avg_price"] + filled_volume = fill["filled_volume"] + + # ATR 기반 손절가/목표가 계산 + atr = candidate.get("atr", filled_price * 0.01) + if atr > 0: + stop_price = filled_price - atr * get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5) + target_price = filled_price + atr * get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 7.0) + else: + stop_price = filled_price * (1 + self.stop_loss_pct) + target_price = filled_price * (1 + self.take_profit_pct) + + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + + # 메모리 포지션 등록 + self.holdings[code] = { + "name": name, + "buy_price": filled_price, + "qty": filled_volume, + "buy_time": buy_time, + "max_price": filled_price, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": atr, + "rsi": candidate.get("rsi", 50.0), + "volume_ratio": candidate.get("volume_ratio", 1.0), + "tail_length_pct": candidate.get("tail_pct", 0.0) * 100, + } + + # ★ DB 즉시 저장 (이벤트 발생 즉시 → 재시작 복원 가능) + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "UPBIT_TAIL_CATCH_3M", + "avg_buy_price": filled_price, + "current_price": filled_price, + "stop_price": stop_price, + "target_price": target_price, + "max_price": filled_price, + "atr_entry": atr, + "target_qty": filled_volume, + "current_qty": filled_volume, + "total_invested": filled_price * filled_volume, + "status": "HOLDING", + "buy_date": buy_time, + "rsi": candidate.get("rsi", 50.0), + "volume_ratio": candidate.get("volume_ratio", 1.0), + "tail_length_pct": candidate.get("tail_pct", 0.0) * 100, + }) + + # WS 구독 추가 → 이후 매도 체크는 WS 기반 + self.ws_cache.subscribe(code) + + logger.info( + f"✅ [매수체결] {name}({code}): " + f"{filled_price:,.4f}원 × {filled_volume:.6f} " + f"| 손절={stop_price:,.4f} / 목표={target_price:,.4f}" + ) + send_mm( + f"🟢 **매수체결** {name}({code})\n" + f"체결가: {filled_price:,.4f}원 | 수량: {filled_volume:.6f}\n" + f"손절: {stop_price:,.4f} / 목표: {target_price:,.4f}\n" + f"보유: {len(self.holdings)}종목" + ) + return True + + # -------------------------------------------------------- + # 매도 실행 (★ 체결 기준) + # -------------------------------------------------------- + + def execute_sell(self, signal: dict) -> bool: + """ + 매도 주문 → 체결 확인 → holdings & DB 반영 + + [체결 기준 플로우] + 1. order_market_sell() → UUID 수령 + 2. wait_for_fill(UUID) → state=='done' 폴링 + 3. 실제 avg_price 로 손익 계산 후 DB 이동 + 4. DB: active_trades 삭제 + trade_history 추가 (원자적) + 5. WS 구독 해제 + """ + code = signal["code"] + name = signal["name"] + qty = signal["qty"] + reason = signal["reason"] + + if code not in self.holdings: + logger.warning(f"⚠️ [{name}] 보유 목록에 없음 → 스킵") + return False + + logger.info(f"🟡 [매도주문] {name}({code}) {qty:.6f}개 시장가 | 사유: {reason}") + order_uuid = self.client.order_market_sell(code, qty) + if not order_uuid: + logger.warning(f"❌ [{name}] 매도 주문 실패: {self.client._last_order_error}") + return False + + # ★ 체결 확인 (주문 UUID로 REST 폴링) + fill = self.client.wait_for_fill(order_uuid, timeout_sec=20) + if not fill or fill["filled_volume"] <= 0: + logger.warning(f"❌ [{name}] 매도 체결 미확인 (UUID={order_uuid})") + return False + + sell_price = fill["avg_price"] + buy_price = self.holdings[code]["buy_price"] + profit_pct = (sell_price - buy_price) / buy_price if buy_price else 0 + profit_krw = (sell_price - buy_price) * fill["filled_volume"] + fee = fill.get("paid_fee", 0) + + # ★ DB 즉시 이동 (active_trades → trade_history, 원자적) + self.db.close_trade(code, sell_price, reason) + + # 메모리에서 제거 + del self.holdings[code] + + # 재진입 쿨다운 기록 + self.recently_sold[code] = time.time() + + # WS 구독 해제 (불필요한 데이터 수신 차단) + self.ws_cache.unsubscribe(code) + + logger.info( + f"✅ [매도체결] {name}({code}): " + f"{sell_price:,.4f}원 × {fill['filled_volume']:.6f} " + f"| {profit_pct*100:+.2f}% / {profit_krw:+,.0f}원 | 사유: {reason}" + ) + send_mm( + f"🔴 **매도체결** {name}({code})\n" + f"체결가: {sell_price:,.4f}원 | 사유: {reason}\n" + f"수익률: {profit_pct*100:+.2f}% | 손익: {profit_krw:+,.0f}원 | 수수료: {fee:,.0f}원\n" + f"보유: {len(self.holdings)}종목" + ) + return True + + # -------------------------------------------------------- + # 메인 루프 + # -------------------------------------------------------- + + def run(self): + """ + 메인 루프 + + [흐름] + 1. 전체 KRW 마켓 목록 로드 + 2. WebSocket 스레드 시작 (보유 종목 즉시 구독) + 3. 매도 루프 (2초 주기) → WS 캐시 기반 + 4. 매수 스캔 (UPBIT_SCAN_INTERVAL_SEC 주기) → REST 캔들 + """ + logger.info("🚀 Upbit 단타 트레이더 시작!") + + # KRW 마켓 목록 로드 + all_markets = [ + m["market"] for m in self.client.get_market_list() + if m["market"].startswith("KRW-") + ] + logger.info(f"📋 KRW 마켓 {len(all_markets)}개 로드") + + # WebSocket 시작 (보유 종목 즉시 구독) + self.ws_cache.start(initial_markets=list(self.holdings.keys())) + time.sleep(2) # WS 연결 안정화 대기 + + last_scan_time = 0.0 + scan_interval_sec = get_env_int("UPBIT_SCAN_INTERVAL_SEC", 60) + + try: + while True: + now = time.time() + + # ① 매도 신호 체크 (2초 주기, WS 캐시 기반) + sell_signals = self.check_sell_signals() + for sig in sell_signals: + self.execute_sell(sig) + + # ② 매수 스캔 (scan_interval 주기, REST 기반) + if now - last_scan_time >= scan_interval_sec: + last_scan_time = now + self.db.clear_old_candidates() + + if len(self.holdings) < self.max_stocks: + logger.info(f"🔍 매수 스캔 시작 ({len(all_markets)}개 마켓)") + candidates = self.scan_buy_candidates(all_markets) + logger.info(f"🎯 후보 {len(candidates)}개 발견") + + # 상위 N개 후보 매수 시도 + top_n = get_env_int("UPBIT_BUY_TOP_N", 2) + for cand in candidates[:top_n]: + if len(self.holdings) >= self.max_stocks: + break + # 매수 직전 WS 최신가로 가격 갱신 (슬리피지 최소화) + ws_p = self.ws_cache.get_price(cand["code"], max_age_sec=5.0) + if ws_p: + cand["price"] = ws_p["price"] + self.execute_buy(cand) + + # ③ 2초 대기 (매도 루프 주기) + time.sleep(2) + + except KeyboardInterrupt: + logger.info("⛔ 사용자 종료 요청") + except Exception as e: + logger.exception(f"❌ 메인 루프 예외: {e}") + send_mm(f"🚨 **Upbit 봇 비정상 종료**: {e}") + finally: + self.ws_cache.stop() + logger.info("🛑 Upbit 단타 트레이더 종료") + + +# ============================================================ +# 진입점 +# ============================================================ + +if __name__ == "__main__": + trader = UpbitShortTrader() + trader.run() diff --git a/upbit_short_ver2.py b/upbit_short_ver2.py new file mode 100644 index 0000000..c526175 --- /dev/null +++ b/upbit_short_ver2.py @@ -0,0 +1,1623 @@ +#!/usr/bin/env python3 +""" +upbit_short_ver2.py — 업비트 단타 봇 Ver2 +============================================================================= +Ver1 대비 개선 사항: + 1. [핵심] 비동기 매수/매도 (Async Threading) + - wait_for_fill(20초 블로킹)을 별도 Worker 스레드로 실행 + - 메인 루프(2초 매도 감시)가 단 1ms도 멈추지 않음 + - _buying / _selling set으로 중복 처리 완전 방지 + + 2. MariaDB 연동 (upbit_quant_db) + - SQLite quant_bot.db → MariaDB 192.168.0.141:3306/upbit_quant_db + - pymysql DictCursor 기반, 자동 재접속 포함 + + 3. 기존 아키텍처 100% 유지 + - UpbitWSPriceCache (WebSocket 실시간 체결가 캐시) + - UpbitClient (REST API, JWT 인증) + - 매수/매도 로직 완전 동일 + +[전략: 개미털기(눌림목) 꼬리봉 잡기] + 매수: 3분봉 기준 + - 낙폭(MIN_DROP_RATE) + 회복률(MIN/MAX_RECOVERY_RATIO) + - 망치봉 꼬리(TAIL_RATIO_MIN, TAIL_PCT_MIN) + - RSI < RSI_OVERHEAT_THRESHOLD + 매도: + - 어깨매도: 고점 대비 SHOULDER_CUT_PCT 하락 + - ATR 스캘핑: 본절 복귀 or 고점 ATR 낙폭 + - 목표가 달성 (TAKE_PROFIT_PCT) + - 손절 (STOP_LOSS_PCT) + - 원화 한도 손절 (MAX_LOSS_PER_TRADE_KRW) + +[실행] + python3 upbit_short_ver2.py +""" + +import json +import time +import uuid +import random +import hashlib +import logging +import datetime +import threading +from datetime import datetime as dt +from pathlib import Path +from typing import Dict, List, Optional + +from urllib.parse import urlencode + +import pandas as pd +import requests + +try: + import pymysql + import pymysql.cursors + _PYMYSQL_AVAILABLE = True +except ImportError: + _PYMYSQL_AVAILABLE = False + +try: + import websocket + _WS_AVAILABLE = True +except ImportError: + _WS_AVAILABLE = False + logging.warning("⚠️ websocket-client 미설치 → pip install websocket-client") + +# ============================================================ +# 로깅 설정 +# ============================================================ +logging.basicConfig( + format='[%(asctime)s] %(message)s', + datefmt='%H:%M:%S', + level=logging.INFO, +) +logger = logging.getLogger("UpbitShortV2") + +LOG_RED = "\033[91m" +LOG_YELLOW = "\033[93m" +LOG_GREEN = "\033[92m" +LOG_CYAN = "\033[96m" +LOG_RESET = "\033[0m" + +SCRIPT_DIR = Path(__file__).resolve().parent + +# ============================================================ +# MariaDB 연결 설정 +# ============================================================ +_DB_HOST = "192.168.0.141" +_DB_PORT = 3306 +_DB_USER = "jae" +_DB_PASS = "1234" +_DB_NAME = "upbit_quant_db" + + +# ============================================================ +# MariaDB 기반 UpbitDB (SQLite UpbitDB 대체) +# ============================================================ + +class UpbitDB: + """ + upbit_quant_db MariaDB 연동 클래스 + - env_config : 전략 설정값 읽기 + - kv_store : Upbit API 키 저장/읽기 + - active_trades : 현재 보유 포지션 (재시작 복원용) + - trade_history : 매매 기록 (손익 통계) + - target_candidates: 스캔 후보 목록 + + [멀티스레드 안전] + - threading.Lock()으로 _execute() 직렬화 + - pymysql ping(reconnect=True)으로 자동 재접속 + """ + + def __init__(self): + if not _PYMYSQL_AVAILABLE: + raise RuntimeError("pymysql 미설치 → pip install pymysql") + self._lock = threading.Lock() + self._conn = None + self._connect() + self._init_upbit_keys() + + def _connect(self): + """pymysql 연결""" + self._conn = pymysql.connect( + host=_DB_HOST, port=_DB_PORT, + user=_DB_USER, password=_DB_PASS, + database=_DB_NAME, + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, + read_timeout=30, + write_timeout=30, + ) + logger.debug(f"✅ MariaDB 연결: {_DB_HOST}/{_DB_NAME}") + + def _execute(self, sql: str, params=None): + """스레드 안전 SQL 실행 (자동 재접속 포함)""" + with self._lock: + try: + self._conn.ping(reconnect=True) + except Exception: + self._connect() + cur = self._conn.cursor() + cur.execute(sql, params or ()) + return cur + + def _init_upbit_keys(self): + """kv_store에 Upbit API 키 슬롯 초기화""" + for k, v in [ + ("UPBIT_ACCESS_KEY", ""), + ("UPBIT_SECRET_KEY", ""), + ("UPBIT_SCAN_INTERVAL_SEC", "60"), + ("UPBIT_BUY_TOP_N", "2"), + ]: + self._execute( + "INSERT IGNORE INTO kv_store (k, v) VALUES (%s, %s)", (k, v) + ) + + # ---- env_config 읽기 ---------------------------------------- + + def get_latest_env(self) -> dict: + """env_config 최신 row → {컬럼명: 값} 딕셔너리""" + cur = self._execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1") + row = cur.fetchone() + return dict(row) if row else {} + + # ---- kv_store ----------------------------------------------- + + def get_kv(self, key: str, default: str = "") -> str: + cur = self._execute("SELECT v FROM kv_store WHERE k = %s", (key,)) + row = cur.fetchone() + return row["v"] if row and row["v"] else default + + def set_kv(self, key: str, value: str): + self._execute( + "INSERT INTO kv_store (k, v) VALUES (%s, %s) " + "ON DUPLICATE KEY UPDATE v = VALUES(v)", + (key, value), + ) + + # ---- active_trades ------------------------------------------ + + def load_active_trades(self) -> List[dict]: + """재시작 시 보유 포지션 복원 (HOLDING 상태만)""" + cur = self._execute( + "SELECT * FROM active_trades WHERE status = 'HOLDING'" + ) + return cur.fetchall() or [] + + def upsert_trade(self, trade: dict): + """ + 매수 체결 즉시 active_trades에 원자적 저장 + [즉시 저장 원칙] 이벤트 발생 즉시 DB에 저장 → 재시작 시 포지션 복원 가능 + """ + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + self._execute(""" + INSERT INTO active_trades ( + code, name, strategy, + avg_buy_price, current_price, + stop_price, target_price, max_price, atr_entry, + target_qty, current_qty, total_invested, + status, buy_date, updated_at, + rsi, volume_ratio, tail_length_pct + ) VALUES ( + %(code)s, %(name)s, %(strategy)s, + %(avg_buy_price)s, %(current_price)s, + %(stop_price)s, %(target_price)s, %(max_price)s, %(atr_entry)s, + %(target_qty)s, %(current_qty)s, %(total_invested)s, + %(status)s, %(buy_date)s, %(updated_at)s, + %(rsi)s, %(volume_ratio)s, %(tail_length_pct)s + ) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + strategy = VALUES(strategy), + avg_buy_price = VALUES(avg_buy_price), + current_price = VALUES(current_price), + stop_price = VALUES(stop_price), + target_price = VALUES(target_price), + max_price = VALUES(max_price), + atr_entry = VALUES(atr_entry), + target_qty = VALUES(target_qty), + current_qty = VALUES(current_qty), + total_invested = VALUES(total_invested), + status = VALUES(status), + buy_date = VALUES(buy_date), + updated_at = VALUES(updated_at), + rsi = VALUES(rsi), + volume_ratio = VALUES(volume_ratio), + tail_length_pct = VALUES(tail_length_pct) + """, {**trade, "updated_at": now}) + + def update_trade_max_price(self, code: str, current_price: float, max_price: float): + """매도 체크 루프에서 고점 갱신 시 즉시 저장""" + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + self._execute( + "UPDATE active_trades SET current_price=%s, max_price=%s, updated_at=%s WHERE code=%s", + (current_price, max_price, now, code), + ) + + def close_trade(self, code: str, sell_price: float, sell_reason: str): + """ + 매도 체결 즉시: active_trades 제거 + trade_history 추가 (원자적) + [즉시 저장 원칙] 이벤트 발생 즉시 저장 → 재시작 시 중복 매도 방지 + """ + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + cur = self._execute("SELECT * FROM active_trades WHERE code = %s", (code,)) + row = cur.fetchone() + if not row: + return + + buy_price = float(row["avg_buy_price"] or 0) + qty = float(row["current_qty"] or 0) + buy_date_str = str(row.get("buy_date", "")) + profit_rate = (sell_price - buy_price) / buy_price * 100 if buy_price else 0 + realized_pnl = (sell_price - buy_price) * qty + + hold_minutes = 0 + try: + buy_dt_obj = dt.strptime(buy_date_str[:19], "%Y-%m-%d %H:%M:%S") + hold_minutes = int((dt.now() - buy_dt_obj).total_seconds() / 60) + except Exception: + pass + + self._execute(""" + INSERT INTO trade_history ( + code, name, strategy, + buy_price, sell_price, qty, + profit_rate, realized_pnl, hold_minutes, + buy_date, sell_date, sell_reason, + rsi, volume_ratio, tail_length_pct + ) VALUES (%s,%s,%s, %s,%s,%s, %s,%s,%s, %s,%s,%s, %s,%s,%s) + """, ( + code, row.get("name", ""), row.get("strategy", ""), + buy_price, sell_price, qty, + profit_rate, realized_pnl, hold_minutes, + buy_date_str, now, sell_reason, + row.get("rsi"), row.get("volume_ratio"), row.get("tail_length_pct"), + )) + self._execute("DELETE FROM active_trades WHERE code = %s", (code,)) + + # ---- target_candidates -------------------------------------- + + def upsert_candidate(self, code: str, name: str, score: float, price: float): + """스캔 후보 이벤트 즉시 저장""" + now = dt.now().strftime("%Y-%m-%d %H:%M:%S") + self._execute(""" + INSERT INTO target_candidates (code, name, score, price, scan_time, updated_at) + VALUES (%s, %s, %s, %s, %s, %s) + ON DUPLICATE KEY UPDATE + name=VALUES(name), score=VALUES(score), price=VALUES(price), + scan_time=VALUES(scan_time), updated_at=VALUES(updated_at) + """, (code, name, score, price, now, now)) + + def clear_old_candidates(self, hours: int = 2): + """오래된 후보 정리""" + cutoff = (dt.now() - datetime.timedelta(hours=hours)).strftime("%Y-%m-%d %H:%M:%S") + self._execute("DELETE FROM target_candidates WHERE updated_at < %s", (cutoff,)) + + # ---- Half-Kelly 승률 계산 ----------------------------------- + + def calculate_half_kelly(self, lookback: int = 50) -> float: + """최근 N건 거래 기준 Half-Kelly 비율 (5~50% 클리핑)""" + cur = self._execute( + "SELECT profit_rate FROM trade_history ORDER BY id DESC LIMIT %s", (lookback,) + ) + rows = cur.fetchall() or [] + if not rows: + return 0.25 + profits = [float(r["profit_rate"] or 0) for r in rows] + wins = [p for p in profits if p > 0] + if not wins: + return 0.05 + win_rate = len(wins) / len(profits) + avg_win = sum(wins) / len(wins) + losses = [abs(p) for p in profits if p < 0] + avg_loss = sum(losses) / len(losses) if losses else 1.0 + b = avg_win / avg_loss if avg_loss else 1.0 + kelly = (b * win_rate - (1 - win_rate)) / b if b else 0 + return max(0.05, min(0.5, kelly / 2)) + + def close(self): + try: + self._conn.close() + except Exception: + pass + + +# ============================================================ +# 전역 DB & env 헬퍼 (하드코딩 금지 — 모든 수치는 DB에서 로드) +# ============================================================ + +_db: Optional[UpbitDB] = None + +def _env_raw(key: str, default: str = "") -> str: + env = _db.get_latest_env() if _db else {} + val = env.get(key) + if not val: + val = _db.get_kv(key) if _db else "" + if not val: + val = default + if isinstance(val, str) and "#" in val: + val = val.split("#")[0].strip() + return str(val) if val is not None else str(default) + +def get_env_float(key: str, default: float) -> float: + try: + return float(_env_raw(key, str(default))) + except (ValueError, TypeError): + return default + +def get_env_int(key: str, default: int) -> int: + try: + return int(float(_env_raw(key, str(default)))) + except (ValueError, TypeError): + return default + +def get_env_bool(key: str, default: bool = False) -> bool: + return _env_raw(key, str(default)).lower() in ("true", "1", "yes") + +def get_env_str(key: str, default: str = "") -> str: + return _env_raw(key, default) + + +# ============================================================ +# Mattermost 알림 +# ============================================================ + +def send_mm(msg: str): + """Mattermost 알림 전송 (실패 시 로그만 남기고 봇 계속 실행)""" + try: + server = get_env_str("MM_SERVER_URL", "") + token = get_env_str("MM_BOT_TOKEN_", "") + channel = get_env_str("MATTERMOST_CHANNEL", "upbit") + if not server or not token: + return + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + r = requests.get(f"{server}/api/v4/channels/name/{channel}", headers=headers, timeout=5) + if r.status_code != 200: + return + channel_id = r.json().get("id", "") + requests.post( + f"{server}/api/v4/posts", + headers=headers, + json={"channel_id": channel_id, "message": msg}, + timeout=5, + ) + except Exception as e: + logger.debug(f"MM 발송 실패: {e}") + + +# ============================================================ +# WebSocket 실시간 체결가 캐시 (ver1과 동일, 그대로 유지) +# ============================================================ + +class UpbitWSPriceCache: + """ + Upbit WebSocket 실시간 체결가 캐시 + - URL : wss://api.upbit.com/websocket/v1 + - 타입 : trade (체결 틱) → SIMPLE 포맷 + - 캐시 : {market: {price, volume, timestamp}} + - 자동 재연결: 끊기면 5초 후 재시도 + - 구독 갱신: subscribe()/unsubscribe() 호출 즉시 서버에 반영 + + [SIMPLE 포맷 주요 필드] + cd : 마켓코드 (KRW-BTC 등) + tp : 체결가 (trade price) + tv : 체결량 (trade volume) + tms : 체결 타임스탬프 (ms) + """ + + WS_URL = "wss://api.upbit.com/websocket/v1" + + def __init__(self): + self._cache: Dict[str, dict] = {} + self._lock = threading.Lock() + self._codes = set() + self._ws: Optional["websocket.WebSocketApp"] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self.is_active = False + + # ---- 구독 관리 ---- + + def subscribe(self, *markets: str): + """마켓 추가 구독 → 즉시 서버에 재구독 메시지 전송""" + with self._lock: + before = len(self._codes) + self._codes.update(markets) + changed = len(self._codes) != before + if changed and self._ws and self.is_active: + self._send_subscribe() + + def unsubscribe(self, *markets: str): + """마켓 구독 해제 → 즉시 서버에 재구독 메시지 전송""" + with self._lock: + self._codes.difference_update(markets) + for m in markets: + self._cache.pop(m, None) + if self._ws and self.is_active: + self._send_subscribe() + + def _send_subscribe(self): + """현재 구독 목록으로 WebSocket 구독 메시지 전송""" + with self._lock: + codes = list(self._codes) + if not codes or not self._ws: + return + payload = json.dumps([ + {"ticket": str(uuid.uuid4())}, + {"type": "trade", "codes": codes, "isOnlyRealtime": True}, + {"format": "SIMPLE"}, + ]) + try: + self._ws.send(payload) + logger.info(f"[WS] 구독 갱신: {len(codes)}개 마켓") + except Exception as e: + logger.warning(f"[WS] 구독 전송 실패: {e}") + + # ---- 가격 조회 ---- + + def get_price(self, market: str, max_age_sec: float = 8.0) -> Optional[dict]: + """ + 캐시에서 현재 체결가 반환 + - max_age_sec 이내 수신분만 유효 (오래된 건 REST fallback 유도) + """ + with self._lock: + data = self._cache.get(market) + if not data: + return None + if time.time() - data["timestamp"] > max_age_sec: + return None + return data + + # ---- WebSocket 콜백 ---- + + def _on_open(self, ws): + self.is_active = True + logger.info("[WS] Upbit WebSocket 연결 완료") + self._send_subscribe() + + def _on_message(self, ws, raw): + """체결 틱 수신 → price_cache 갱신""" + try: + if isinstance(raw, bytes): + raw = raw.decode("utf-8") + data = json.loads(raw) + market = data.get("cd") + price = data.get("tp") + volume = data.get("tv") + if market and price: + with self._lock: + self._cache[market] = { + "price": float(price), + "volume": float(volume) if volume else 0.0, + "timestamp": time.time(), + } + except Exception as e: + logger.debug(f"[WS] 메시지 파싱 오류: {e}") + + def _on_error(self, ws, error): + logger.warning(f"[WS] 오류: {error}") + self.is_active = False + + def _on_close(self, ws, code, msg): + logger.warning(f"[WS] 연결 종료 (code={code})") + self.is_active = False + + # ---- 시작/종료 ---- + + def start(self, initial_markets: list = None): + """WebSocket 백그라운드 스레드 시작""" + if not _WS_AVAILABLE: + logger.error("websocket-client 미설치 → WebSocket 비활성화") + return + if initial_markets: + with self._lock: + self._codes.update(initial_markets) + self._stop.clear() + self._thread = threading.Thread(target=self._run_loop, daemon=True, name="UpbitWS") + self._thread.start() + logger.info("[WS] WebSocket 스레드 시작") + + def stop(self): + self._stop.set() + if self._ws: + try: + self._ws.close() + except Exception: + pass + self.is_active = False + logger.info("[WS] WebSocket 종료") + + def _run_loop(self): + """WebSocket 자동 재연결 루프 (5초 간격)""" + while not self._stop.is_set(): + try: + self._ws = websocket.WebSocketApp( + self.WS_URL, + on_open=self._on_open, + on_message=self._on_message, + on_error=self._on_error, + on_close=self._on_close, + ) + # ping/pong으로 120초 idle timeout 방지 + self._ws.run_forever(ping_interval=30, ping_timeout=10) + except Exception as e: + logger.warning(f"[WS] run_forever 예외: {e}") + finally: + self.is_active = False + if not self._stop.is_set(): + logger.info("[WS] 5초 후 재연결...") + time.sleep(5) + + +# ============================================================ +# Upbit REST API 클라이언트 (ver1과 동일) +# ============================================================ + +class UpbitClient: + """ + Upbit REST API 클라이언트 + - Exchange API (주문/계좌): JWT 인증 필요 + - Quotation API (시세/캔들): 인증 불필요 + - HTTP 429 자동 재시도 (점진적 대기) + + [레이트리밋] + - 주문 API: 분당 200회 + - 시세 API: 분당 600회 + → 요청 간 0.12초 갭 유지 (안전 마진) + """ + + BASE = "https://api.upbit.com/v1" + + def __init__(self, access_key: str, secret_key: str): + self.access_key = access_key + self.secret_key = secret_key + self._last_t = 0.0 + self._last_order_error = None + + def _throttle(self, gap: float = 0.12): + """요청 간격 조절 (레이트리밋 방지)""" + elapsed = time.time() - self._last_t + if elapsed < gap: + time.sleep(gap - elapsed) + self._last_t = time.time() + + # ---- JWT 인증 ---- + + def _make_jwt(self, query_string: str = "") -> str: + try: + import jwt as pyjwt + except ImportError: + raise RuntimeError("PyJWT 필요: pip install PyJWT") + payload = {"access_key": self.access_key, "nonce": str(uuid.uuid4())} + if query_string: + qh = hashlib.sha512(query_string.encode()).hexdigest() + payload["query_hash"] = qh + payload["query_hash_alg"] = "SHA512" + return pyjwt.encode(payload, self.secret_key, algorithm="HS256") + + def _auth_headers(self, qs: str = "") -> dict: + return {"Authorization": f"Bearer {self._make_jwt(qs)}"} + + # ---- 공통 GET / POST ---- + + def _get(self, path: str, params: dict = None, auth: bool = False, retries: int = 5) -> Optional[dict]: + self._throttle() + url = f"{self.BASE}{path}" + qs = urlencode(params, doseq=True) if params else "" + hdrs = self._auth_headers(qs) if auth else {} + for attempt in range(retries): + try: + r = requests.get(url, params=params, headers=hdrs, timeout=10) + if r.status_code == 429: + wait = 1.0 + attempt + logger.warning(f"⏳ REST 429 → {wait:.0f}초 대기 (attempt {attempt+1})") + time.sleep(wait) + continue + if r.status_code == 200: + return r.json() + logger.warning(f"[REST] GET {path} → {r.status_code}: {r.text[:200]}") + return None + except requests.RequestException as e: + wait = (2 ** attempt) + random.uniform(0.3, 1.0) + logger.warning(f"⚠️ 네트워크 오류 ({attempt+1}/{retries}): {e} → {wait:.1f}초") + time.sleep(wait) + return None + + def _post(self, path: str, body: dict, retries: int = 3) -> Optional[dict]: + self._throttle() + url = f"{self.BASE}{path}" + qs = urlencode(body, doseq=True) + hdrs = {**self._auth_headers(qs), "Content-Type": "application/json"} + for attempt in range(retries): + try: + r = requests.post(url, json=body, headers=hdrs, timeout=10) + if r.status_code == 429: + wait = 3.0 + attempt * 2 + logger.warning(f"⏳ REST 429 → {wait:.0f}초 대기 (attempt {attempt+1})") + time.sleep(wait) + continue + data = r.json() + if r.status_code in (200, 201): + return data + self._last_order_error = data + logger.warning(f"[REST] POST {path} → {r.status_code}: {data}") + return None + except requests.RequestException as e: + wait = (2 ** attempt) + random.uniform(0.3, 1.0) + logger.warning(f"⚠️ 네트워크 오류 ({attempt+1}/{retries}): {e}") + time.sleep(wait) + return None + + # ---- 계좌 ---- + + def get_accounts(self) -> list: + return self._get("/accounts", auth=True) or [] + + def get_krw_balance(self) -> float: + """가용 KRW 잔고 반환""" + for acc in self.get_accounts(): + if acc.get("currency") == "KRW": + return float(acc.get("balance", 0)) + return 0.0 + + # ---- 시세 (Quotation) ---- + + def get_market_list(self) -> list: + return self._get("/market/all") or [] + + def get_ticker(self, markets: list) -> list: + return self._get("/ticker", params={"markets": ",".join(markets)}) or [] + + def get_candles_minutes(self, market: str, unit: int = 3, count: int = 50) -> list: + """ + 분봉 조회 (unit: 1,3,5,10,15,30,60,240) + 응답: 최신 봉이 인덱스 0 (시간 역순) → candles_to_df()에서 정렬 + """ + return self._get( + f"/candles/minutes/{unit}", + params={"market": market, "count": count} + ) or [] + + def get_candles_days(self, market: str, count: int = 30) -> list: + return self._get("/candles/days", params={"market": market, "count": count}) or [] + + # ---- 주문 ---- + + def order_market_buy(self, market: str, price_krw: float) -> Optional[str]: + """ + 시장가 매수 주문 (금액 기준) + Returns: 주문 UUID → wait_for_fill()에 전달 + """ + body = { + "market": market, + "side": "bid", + "price": str(int(price_krw)), + "ord_type": "price", + } + result = self._post("/orders", body) + return result.get("uuid") if result else None + + def order_market_sell(self, market: str, volume: float) -> Optional[str]: + """ + 시장가 매도 주문 (수량 기준) + Returns: 주문 UUID → wait_for_fill()에 전달 + """ + body = { + "market": market, + "side": "ask", + "volume": str(volume), + "ord_type": "market", + } + result = self._post("/orders", body) + return result.get("uuid") if result else None + + def wait_for_fill(self, order_uuid: str, timeout_sec: int = 20, poll: float = 0.5) -> Optional[dict]: + """ + ★ 체결 확인 (폴링) + - state == 'done'이 될 때까지 REST 폴링 + - 실제 체결가(avg_price) / 체결량(filled_volume) 반환 + - timeout_sec 초 내 미체결 시 None 반환 + + Upbit 주문 상태: + wait : 대기 (미체결) + watch : 예약 (예약주문) + done : 완료 (체결 완료) + cancel : 취소 + """ + deadline = time.time() + timeout_sec + while time.time() < deadline: + data = self._get("/order", params={"uuid": order_uuid}, auth=True) + if not data: + time.sleep(poll) + continue + + state = data.get("state") + + if state == "done": + trades = data.get("trades", []) + if trades: + total_vol = sum(float(t["volume"]) for t in trades) + total_cost = sum(float(t["price"]) * float(t["volume"]) for t in trades) + avg_price = total_cost / total_vol if total_vol else 0.0 + paid_fee = sum(float(t.get("funds", 0)) * 0.0005 for t in trades) + else: + avg_price = float(data.get("avg_price") or data.get("price", 0)) + total_vol = float(data.get("executed_volume", 0)) + paid_fee = float(data.get("paid_fee", 0)) + + return { + "avg_price": avg_price, + "filled_volume": total_vol, + "paid_fee": paid_fee, + } + + if state == "cancel": + logger.warning(f"[체결확인] 주문 취소됨: {order_uuid}") + return None + + time.sleep(poll) + + logger.warning(f"[체결확인] 타임아웃 ({timeout_sec}s): {order_uuid}") + return None + + def cancel_order(self, order_uuid: str) -> bool: + hdrs = self._auth_headers(f"uuid={order_uuid}") + try: + r = requests.delete( + f"{self.BASE}/order", + params={"uuid": order_uuid}, + headers=hdrs, timeout=10 + ) + return r.status_code == 200 + except Exception: + return False + + +# ============================================================ +# 지표 계산 유틸 +# ============================================================ + +def candles_to_df(candles: list) -> pd.DataFrame: + """ + Upbit 분봉/일봉 리스트 → DataFrame (시간 오름차순) + Upbit 응답은 최신 봉이 인덱스 0 (역순) → sort 필수 + """ + if not candles: + return pd.DataFrame() + df = pd.DataFrame(candles).rename(columns={ + "opening_price": "open", + "high_price": "high", + "low_price": "low", + "trade_price": "close", + "candle_acc_trade_volume": "volume", + "candle_date_time_kst": "datetime", + }) + for col in ("open", "high", "low", "close", "volume"): + if col in df.columns: + df[col] = df[col].astype(float) + if "datetime" in df.columns: + df = df.sort_values("datetime").reset_index(drop=True) + return df + + +def calc_rsi(close: pd.Series, period: int = 14) -> float: + """RSI 계산 (마지막 값 반환). 데이터 부족 시 50 반환""" + if len(close) < period + 1: + return 50.0 + delta = close.diff() + gain = delta.where(delta > 0, 0.0).rolling(period).mean() + loss = (-delta.where(delta < 0, 0.0)).rolling(period).mean() + rs = gain / loss.replace(0, float("nan")) + rsi = 100 - (100 / (1 + rs)) + val = rsi.iloc[-1] + return float(val) if not pd.isna(val) else 50.0 + + +def calc_atr(df: pd.DataFrame, period: int = 14) -> float: + """ATR (Average True Range) 계산. 데이터 부족 시 0 반환""" + if df.empty or len(df) < period + 1: + return 0.0 + h, l, c = df["high"], df["low"], df["close"] + tr = pd.concat( + [(h - l), (h - c.shift(1)).abs(), (l - c.shift(1)).abs()], axis=1 + ).max(axis=1) + val = tr.rolling(period).mean().iloc[-1] + return float(val) if not pd.isna(val) else 0.0 + + +# ============================================================ +# 단타 트레이더 Ver2 (핵심 개선: 비동기 매수/매도) +# ============================================================ + +class UpbitShortTraderV2: + """ + Upbit 단타 트레이더 Ver2 (개미털기/눌림목 전략) + + [Ver2 핵심 변경사항] + ★ 비동기 매수/매도 (Non-blocking Thread) + - 기존 Ver1: execute_buy/sell → wait_for_fill(20초) → 메인 루프 블로킹 + - Ver2: 매수/매도를 Worker 스레드로 던지고 메인 루프는 즉시 계속 + - _buying / _selling set으로 중복 처리 완전 방지 + + [아키텍처 다이어그램] + ┌─────────────────────────────────────────────┐ + │ WebSocket 스레드 (daemon) │ + │ → trade 체결 틱 수신 → price_cache 업데이트 │ + └───────────────────┬─────────────────────────┘ + │ (캐시 읽기) + ┌───────────────────▼─────────────────────────┐ + │ 메인 루프 (2초 주기) ← 절대 블로킹 없음! │ + │ ① 매도 신호 체크 → _selling 미포함 종목만 │ + │ → Worker 스레드 생성 (비동기) │ + │ ② 매수 스캔 (60초마다) → _buying 미포함 만 │ + │ → Worker 스레드 생성 (비동기) │ + └───────────────────┬─────────────────────────┘ + │ (스레드로 던짐) + ┌───────────────────▼─────────────────────────┐ + │ Worker 스레드 (daemon, 개수 제한 없음) │ + │ → wait_for_fill(UUID 폴링, 최대 20초) │ + │ → DB 저장 (즉시, 원자적) │ + └─────────────────────────────────────────────┘ + """ + + def __init__(self): + global _db + _db = UpbitDB() + self.db = _db + + # API 키 (kv_store 우선, env_config fallback) + access_key = self.db.get_kv("UPBIT_ACCESS_KEY") or get_env_str("UPBIT_ACCESS_KEY", "") + secret_key = self.db.get_kv("UPBIT_SECRET_KEY") or get_env_str("UPBIT_SECRET_KEY", "") + if not access_key or not secret_key: + logger.warning("⚠️ Upbit API 키 미설정 → DB kv_store 에 UPBIT_ACCESS_KEY/UPBIT_SECRET_KEY 저장 필요") + + self.client = UpbitClient(access_key, secret_key) + self.ws_cache = UpbitWSPriceCache() + + # 보유 포지션 메모리 {code: holding_dict} + self.holdings: Dict[str, dict] = {} + + # ★ Ver2 핵심: 비동기 처리 중 종목 추적 + self._selling: set = set() # 매도 워커 스레드 처리 중인 종목 + self._buying: set = set() # 매수 워커 스레드 처리 중인 종목 + self._trade_lock = threading.Lock() # holdings 딕셔너리 동시 접근 보호 + + # 재진입 쿨다운 {code: timestamp} + self.recently_sold: Dict[str, float] = {} + + # 설정 로드 + self._load_settings() + + # DB에서 보유 포지션 복원 (재시작 시) + self._restore_holdings() + + def _load_settings(self): + """DB env_config에서 전략 파라미터 로드 (모두 get_env_* 사용 — 하드코딩 금지)""" + self.stop_loss_pct = get_env_float("STOP_LOSS_PCT", -0.02) + self.take_profit_pct = get_env_float("TAKE_PROFIT_PCT", 0.05) + self.max_stocks = get_env_int("MAX_STOCKS", 5) + self.min_drop_rate = get_env_float("MIN_DROP_RATE", 0.03) + self.min_recovery_ratio = get_env_float("MIN_RECOVERY_RATIO", 0.30) + self.rsi_overheat = get_env_float("RSI_OVERHEAT_THRESHOLD", 78.0) + self.tail_ratio_min = get_env_float("TAIL_RATIO_MIN", 1.5) + self.tail_pct_min = get_env_float("TAIL_PCT_MIN", 0.003) + self.shoulder_cut_pct = get_env_float("SHOULDER_CUT_PCT", 0.03) + self.shoulder_min_high = get_env_float("SHOULDER_MIN_HIGH_PCT", 0.01) + self.scalp_up_mult = get_env_float("SCALP_ATR_UP_MULT", 1.0) + self.scalp_down_mult = get_env_float("SCALP_ATR_DOWN_MULT", 0.2) + self.scalp_drop_mult = get_env_float("SCALP_ATR_DROP_MULT", 1.0) + self.reentry_cooldown = get_env_int("REENTRY_COOLDOWN_SEC", 300) + self.round_trip_cost = get_env_float("ROUND_TRIP_COST_PCT", 0.001) + + logger.info( + f"⚙️ 설정 로드 | 손절={self.stop_loss_pct*100:.1f}% " + f"| 익절={self.take_profit_pct*100:.1f}% " + f"| 최대종목={self.max_stocks}" + ) + + def _restore_holdings(self): + """재시작 시 DB active_trades → 메모리 복원 + WS 재구독""" + rows = self.db.load_active_trades() + for r in rows: + code = r["code"] + self.holdings[code] = { + "name": r["name"], + "buy_price": float(r["avg_buy_price"] or 0), + "qty": float(r["current_qty"] or 0), + "buy_time": str(r.get("buy_date", "")), + "max_price": float(r.get("max_price") or r["avg_buy_price"] or 0), + "stop_price": float(r.get("stop_price") or 0) or + float(r["avg_buy_price"] or 0) * (1 + self.stop_loss_pct), + "target_price": float(r.get("target_price") or 0) or + float(r["avg_buy_price"] or 0) * (1 + self.take_profit_pct), + "atr_entry": float(r.get("atr_entry") or 0), + "rsi": float(r.get("rsi") or 50.0), + "volume_ratio": float(r.get("volume_ratio") or 1.0), + "tail_length_pct": float(r.get("tail_length_pct") or 0.0), + } + if rows: + logger.info(f"♻️ 보유 포지션 복원: {list(self.holdings.keys())}") + + # -------------------------------------------------------- + # 매수 스캔 (REST 캔들 기반, 60초 주기) + # -------------------------------------------------------- + + def scan_buy_candidates(self, market_list: list) -> list: + """ + 전체 KRW 마켓 순회 → 개미털기(눌림목) 조건 필터링 + + [조건 순서] + 1. 낙폭: 시가 대비 저가 낙폭 ≥ MIN_DROP_RATE + 2. 회복률: 저가→현재가 회복률 [MIN_RECOVERY_RATIO ~ MAX_RECOVERY_RATIO] + 3. 꼬리봉: 밑꼬리/몸통 비율 ≥ TAIL_RATIO_MIN, 꼬리% ≥ TAIL_PCT_MIN + 4. 고점 추격 방지: 현재가 < 당일고점 × HIGH_PRICE_CHASE_THRESHOLD + 5. RSI: < RSI_OVERHEAT_THRESHOLD + 6. 점수 계산 후 내림차순 정렬 + """ + candidates = [] + + for market in market_list: + with self._trade_lock: + if market in self.holdings: + continue + if market in self._buying: + continue + + # 재진입 쿨다운 + if market in self.recently_sold: + if time.time() - self.recently_sold[market] < self.reentry_cooldown: + continue + + try: + # 분봉 단위: DB 설정에서 동적 로드 (기본 3분봉) + # MA60 계산을 위해 최소 80봉 확보 (50→80으로 상향) + candle_unit = get_env_int("UPBIT_CANDLE_UNIT", 3) + candles = self.client.get_candles_minutes(market, unit=candle_unit, count=80) + if not candles or len(candles) < 20: + continue + + df = candles_to_df(candles) + current_price = df["close"].iloc[-1] + + # ======================================================= + # 🛡️ [추세선 필터] 역배열(하락 추세) 종목 필터링 + # MA20 < MA60 인 역배열 종목은 꼬리잡기 진입 금지 + # → 세력이 코인을 버리고 있는 구간이면 꼬리를 잡아도 반등 없이 추가 하락 + # ------------------------------------------------------- + ma20 = df["close"].rolling(20).mean().iloc[-1] + ma60 = df["close"].rolling(60).mean().iloc[-1] + + if pd.isna(ma20) or pd.isna(ma60): + # 봉 수 부족으로 MA 계산 불가 → 스킵 + continue + + if ma20 < ma60: + logger.info( + f"{LOG_YELLOW}[탈락-역배열] {market}: " + f"MA20({ma20:,.0f}) < MA60({ma60:,.0f}){LOG_RESET}" + ) + continue + # ======================================================= + + day_open = df["open"].iloc[0] + day_high = df["high"].max() + valid_lows = df["low"][df["low"] > 0] + day_low = float(valid_lows.min()) if not valid_lows.empty else df["low"].min() + day_range = day_high - day_low + + # [1] 낙폭 필터 + drop_rate = (day_open - day_low) / day_open if day_open > 0 else 0 + if drop_rate < self.min_drop_rate: + logger.info(f"{LOG_YELLOW}[탈락-낙폭] {market}: {drop_rate*100:.1f}% < {self.min_drop_rate*100:.1f}%{LOG_RESET}") + continue + + # [2] 회복률 필터 + recovery = (current_price - day_low) / day_range if day_range > 0 else 0 + max_rec = get_env_float("MAX_RECOVERY_RATIO", 0.8) + if not (self.min_recovery_ratio <= recovery <= max_rec): + logger.info(f"{LOG_YELLOW}[탈락-회복] {market}: {recovery*100:.1f}%{LOG_RESET}") + continue + + # [3] 망치봉 꼬리 계산 (마지막 봉 기준) + last = df.iloc[-1] + c_open, c_close = last["open"], last["close"] + c_low, c_high = last["low"], last["high"] + body_top = max(c_open, c_close) + body_bottom = min(c_open, c_close) + body_len = max(body_top - body_bottom, 1.0) + tail_len = max(body_bottom - c_low, 0.0) + tail_ratio = tail_len / body_len + tail_pct = tail_len / c_low if c_low > 0 else 0 + + # 마지막 봉에 꼬리가 없으면 최근 5봉 재탐색 (장외 도지 대응) + if tail_len <= 0 and len(df) >= 2: + for idx in range(len(df) - 2, max(-1, len(df) - 6), -1): + c = df.iloc[idx] + bt = max(c["open"], c["close"]) + bb = min(c["open"], c["close"]) + bl = max(bt - bb, 1.0) + tl = max(bb - c["low"], 0.0) + if tl > 0: + tail_ratio = tl / bl + tail_pct = tl / c["low"] if c["low"] > 0 else 0 + tail_len = tl + break + + if tail_ratio < self.tail_ratio_min or tail_pct < self.tail_pct_min: + logger.info(f"{LOG_YELLOW}[탈락-꼬리] {market}: 비율={tail_ratio:.2f}, pct={tail_pct*100:.2f}%{LOG_RESET}") + continue + + # [4] 고점 추격 방지 + hc_thr = get_env_float("HIGH_PRICE_CHASE_THRESHOLD", 0.96) + if current_price >= day_high * hc_thr: + logger.info(f"{LOG_YELLOW}[탈락-고점추격] {market}{LOG_RESET}") + continue + + # [5] RSI 과열 방지 + rsi = calc_rsi(df["close"]) + if rsi >= self.rsi_overheat: + logger.info(f"{LOG_YELLOW}[탈락-RSI] {market}: {rsi:.1f}{LOG_RESET}") + continue + + # [6] 거래량 비율 + avg_vol = df["volume"].mean() + last_vol = df["volume"].iloc[-1] + volume_ratio = last_vol / avg_vol if avg_vol > 0 else 1.0 + + # [7] ATR / 점수 + atr = calc_atr(df) + score = get_env_float("TAIL_SCORE_BASE", 5.0) + \ + tail_ratio * get_env_float("TAIL_SCORE_RATIO_MULT", 2.0) + + candidates.append({ + "code": market, + "name": market.replace("KRW-", ""), + "price": current_price, + "score": score, + "atr": atr, + "rsi": rsi, + "volume_ratio": volume_ratio, + "tail_ratio": tail_ratio, + "tail_pct": tail_pct, + "recovery": recovery, + "drop_rate": drop_rate, + }) + logger.info( + f"{LOG_GREEN}🎯 [후보] {market} | 가격:{current_price:,.0f} " + f"| 꼬리:{tail_ratio:.1f}x | RSI:{rsi:.1f} | 점수:{score:.1f}{LOG_RESET}" + ) + # 후보 DB 즉시 저장 (이벤트 발생 즉시 저장 원칙) + self.db.upsert_candidate(market, market.replace("KRW-", ""), score, current_price) + + except Exception as e: + logger.debug(f"[스캔] {market} 오류: {e}") + + # 마켓 간 레이트리밋 방지 + time.sleep(random.uniform(0.12, 0.20)) + + candidates.sort(key=lambda x: x["score"], reverse=True) + return candidates + + # -------------------------------------------------------- + # 매도 신호 체크 (WebSocket 캐시 기반, 2초 주기) + # -------------------------------------------------------- + + def check_sell_signals(self) -> list: + """ + 보유 포지션 매도 신호 체크 + - 가격 소스: WebSocket 캐시 우선 (max_age=8초), 만료 시 REST fallback + - 이미 _selling에 있는 종목은 체크 스킵 (중복 매도 방지) + + [매도 우선순위] + 1. 어깨매도 : 고점 대비 SHOULDER_CUT_PCT 이상 하락 (수익 보존) + 2. ATR 스캘핑: 고점→본절 복귀 또는 고점→ATR 하락 + 3. 목표가 달성 + 4. 손절 (% 또는 원화 기준) + """ + if not self.holdings: + return [] + + signals = [] + min_hold = get_env_float("MIN_HOLD_AFTER_BUY_SEC", 10.0) + + with self._trade_lock: + holdings_snapshot = dict(self.holdings) + + for code, h in holdings_snapshot.items(): + # ★ 이미 매도 처리 중인 종목 스킵 (비동기 중복 방지) + if code in self._selling: + continue + + try: + # 최소 보유 시간 체크 + if h.get("buy_time"): + try: + buy_dt = dt.strptime(h["buy_time"][:19], "%Y-%m-%d %H:%M:%S") + if (dt.now() - buy_dt).total_seconds() < min_hold: + continue + except Exception: + pass + + # ★ 현재가: WebSocket 캐시 우선 → REST fallback + ws_data = self.ws_cache.get_price(code, max_age_sec=8.0) + if ws_data: + current_price = ws_data["price"] + _src = "WS" + else: + tickers = self.client.get_ticker([code]) + if not tickers: + continue + current_price = float(tickers[0]["trade_price"]) + _src = "REST" + + if current_price <= 0: + continue + + buy_price = h["buy_price"] + qty = h["qty"] + max_price = h.get("max_price", buy_price) + + # 고점 갱신 (즉시 DB 저장) + if current_price > max_price: + max_price = current_price + with self._trade_lock: + if code in self.holdings: + self.holdings[code]["max_price"] = max_price + self.db.update_trade_max_price(code, current_price, max_price) + + profit_pct = (current_price - buy_price) / buy_price if buy_price else 0 + profit_krw = (current_price - buy_price) * qty + + atr = h.get("atr_entry", 0) or buy_price * 0.01 + stop_price = h.get("stop_price", buy_price * (1 + self.stop_loss_pct)) + target_price = h.get("target_price", buy_price * (1 + self.take_profit_pct)) + + sell_reason = None + + # [1] 어깨매도: 고점이 진입가 대비 충분히 올랐을 때만 적용 + drop_from_high = (max_price - current_price) / max_price if max_price > 0 else 0 + high_above_entry = (max_price - buy_price) / buy_price if buy_price > 0 else 0 + + # 수수료 반영 손익분기 하한 자동 계산 + min_high_required = ( + (1 + self.round_trip_cost + get_env_float("SHOULDER_MIN_NET_PCT", 0.001)) / + (1 - self.shoulder_cut_pct) - 1 + ) if (1 - self.shoulder_cut_pct) > 0 else 0.01 + eff_shoulder_min = max(self.shoulder_min_high, min_high_required) + + if drop_from_high >= self.shoulder_cut_pct and high_above_entry >= eff_shoulder_min: + sell_reason = f"어깨매도(고점-{drop_from_high*100:.1f}%)" + + # [2] ATR 스캘핑: 고점 달성 후 본절 근처로 내려오면 매도 + if not sell_reason and atr > 0: + if max_price >= buy_price + atr * self.scalp_up_mult and \ + current_price <= buy_price + atr * self.scalp_down_mult: + sell_reason = "스캘핑_본절사수" + if not sell_reason and \ + current_price < (max_price - atr * self.scalp_drop_mult) and profit_pct > 0: + sell_reason = "스캘핑_익절보존" + + # [3] 목표가 달성 + if not sell_reason and current_price >= target_price: + sell_reason = f"목표달성({profit_pct*100:+.2f}%)" + + # [4] 손절 (% 기준) + if not sell_reason and (current_price <= stop_price or profit_pct <= self.stop_loss_pct): + sell_reason = f"손절({profit_pct*100:.2f}%)" + + # [5] 원화 손실 한도 (금액 기준 손절) + max_loss_krw = get_env_float("MAX_LOSS_PER_TRADE_KRW", 50000) + if not sell_reason and profit_krw <= -max_loss_krw: + sell_reason = f"금액손절({profit_krw:+,.0f}원)" + + if sell_reason: + signals.append({ + "code": code, + "name": h["name"], + "reason": sell_reason, + "profit_pct": profit_pct, + "qty": qty, + "price": current_price, + "_src": _src, + }) + logger.info( + f"💸 [매도신호/{_src}] {h['name']}({code}): " + f"{sell_reason} | {profit_pct*100:+.2f}%" + ) + + except Exception as e: + logger.error(f"[매도체크] {code} 오류: {e}") + + return signals + + # -------------------------------------------------------- + # ★ Ver2 핵심: 비동기 매수 실행 (Worker 스레드) + # -------------------------------------------------------- + + def _buy_worker(self, candidate: dict): + """ + 매수 Worker 스레드 (메인 루프와 독립 실행) + - wait_for_fill(20초 최대) 이 블로킹되어도 메인 루프는 계속 돌아감 + - 완료 후 반드시 _buying에서 제거 (finally 보장) + """ + code = candidate["code"] + try: + self._execute_buy_internal(candidate) + except Exception as e: + logger.error(f"[매수워커] {code} 예외: {e}") + finally: + self._buying.discard(code) + + def _execute_buy_internal(self, candidate: dict) -> bool: + """ + 실제 매수 실행 로직 (Worker 스레드 내에서 실행) + - 주문 → 체결 확인 → holdings & DB 반영 → WS 구독 추가 + """ + code = candidate["code"] + name = candidate["name"] + price = candidate["price"] + + with self._trade_lock: + 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 + + cash = self.client.get_krw_balance() + slot = get_env_float("SLOT_MONEY_DEFAULT", 100000) + if cash < slot * 1.05: + logger.warning(f"⚠️ [{name}] 잔고 부족: {cash:,.0f}원 < {slot*1.05:,.0f}원") + return False + + amount = min(slot, cash * 0.90) + + logger.info(f"🟡 [매수주문] {name}({code}) {amount:,.0f}원 시장가") + order_uuid = self.client.order_market_buy(code, amount) + if not order_uuid: + logger.warning(f"❌ [{name}] 매수 주문 실패: {self.client._last_order_error}") + return False + + # ★ 체결 확인 (최대 20초 블로킹 → 하지만 이미 별도 스레드이므로 안전) + fill = self.client.wait_for_fill(order_uuid, timeout_sec=20) + if not fill or fill["filled_volume"] <= 0: + logger.warning(f"❌ [{name}] 매수 체결 미확인 (UUID={order_uuid})") + self.client.cancel_order(order_uuid) + return False + + filled_price = fill["avg_price"] + filled_volume = fill["filled_volume"] + + # ATR 기반 손절가/목표가 계산 + atr = candidate.get("atr", filled_price * 0.01) + if atr > 0: + stop_price = filled_price - atr * get_env_float("STOP_ATR_MULTIPLIER_TAIL", 2.5) + target_price = filled_price + atr * get_env_float("TARGET_ATR_MULTIPLIER_TAIL", 7.0) + else: + stop_price = filled_price * (1 + self.stop_loss_pct) + target_price = filled_price * (1 + self.take_profit_pct) + + buy_time = dt.now().strftime("%Y-%m-%d %H:%M:%S") + + # 메모리 포지션 등록 (락 보호) + with self._trade_lock: + self.holdings[code] = { + "name": name, + "buy_price": filled_price, + "qty": filled_volume, + "buy_time": buy_time, + "max_price": filled_price, + "stop_price": stop_price, + "target_price": target_price, + "atr_entry": atr, + "rsi": candidate.get("rsi", 50.0), + "volume_ratio": candidate.get("volume_ratio", 1.0), + "tail_length_pct": candidate.get("tail_pct", 0.0) * 100, + } + + # [즉시 저장 원칙] 이벤트 발생 즉시 DB 저장 (재시작 복원 가능) + self.db.upsert_trade({ + "code": code, + "name": name, + "strategy": "UPBIT_TAIL_CATCH", + "avg_buy_price": filled_price, + "current_price": filled_price, + "stop_price": stop_price, + "target_price": target_price, + "max_price": filled_price, + "atr_entry": atr, + "target_qty": filled_volume, + "current_qty": filled_volume, + "total_invested": filled_price * filled_volume, + "status": "HOLDING", + "buy_date": buy_time, + "rsi": candidate.get("rsi", 50.0), + "volume_ratio": candidate.get("volume_ratio", 1.0), + "tail_length_pct": candidate.get("tail_pct", 0.0) * 100, + }) + + # WS 구독 추가 → 이후 매도 체크는 WS 기반 + self.ws_cache.subscribe(code) + + logger.info( + f"✅ [매수체결] {name}({code}): " + f"{filled_price:,.4f}원 × {filled_volume:.8f} " + f"| 손절={stop_price:,.4f} / 목표={target_price:,.4f}" + ) + send_mm( + f"🟢 **매수체결** {name}({code})\n" + f"체결가: {filled_price:,.4f}원 | 수량: {filled_volume:.8f}\n" + f"손절: {stop_price:,.4f} / 목표: {target_price:,.4f}\n" + f"보유: {len(self.holdings)}종목" + ) + return True + + # -------------------------------------------------------- + # ★ Ver2 핵심: 비동기 매도 실행 (Worker 스레드) + # -------------------------------------------------------- + + def _sell_worker(self, signal: dict): + """ + 매도 Worker 스레드 (메인 루프와 독립 실행) + - wait_for_fill(20초 최대) 이 블로킹되어도 메인 루프는 계속 돌아감 + - 완료 후 반드시 _selling에서 제거 (finally 보장) + """ + code = signal["code"] + try: + self._execute_sell_internal(signal) + except Exception as e: + logger.error(f"[매도워커] {code} 예외: {e}") + finally: + self._selling.discard(code) + + def _execute_sell_internal(self, signal: dict) -> bool: + """ + 실제 매도 실행 로직 (Worker 스레드 내에서 실행) + - 주문 → 체결 확인 → holdings & DB 반영 → WS 구독 해제 + """ + code = signal["code"] + name = signal["name"] + qty = signal["qty"] + reason = signal["reason"] + + with self._trade_lock: + if code not in self.holdings: + logger.warning(f"⚠️ [{name}] 보유 목록에 없음 → 스킵") + return False + + logger.info(f"🟡 [매도주문] {name}({code}) {qty:.8f}개 시장가 | 사유: {reason}") + order_uuid = self.client.order_market_sell(code, qty) + if not order_uuid: + logger.warning(f"❌ [{name}] 매도 주문 실패: {self.client._last_order_error}") + return False + + # ★ 체결 확인 (최대 20초 블로킹 → 하지만 이미 별도 스레드이므로 안전) + fill = self.client.wait_for_fill(order_uuid, timeout_sec=20) + if not fill or fill["filled_volume"] <= 0: + logger.warning(f"❌ [{name}] 매도 체결 미확인 (UUID={order_uuid})") + return False + + sell_price = fill["avg_price"] + buy_price = None + with self._trade_lock: + if code in self.holdings: + buy_price = self.holdings[code]["buy_price"] + buy_price = buy_price or signal.get("price", sell_price) + + profit_pct = (sell_price - buy_price) / buy_price if buy_price else 0 + profit_krw = (sell_price - buy_price) * fill["filled_volume"] + fee = fill.get("paid_fee", 0) + + # [즉시 저장 원칙] DB 이동 (active_trades → trade_history, 원자적) + self.db.close_trade(code, sell_price, reason) + + # 메모리에서 제거 (락 보호) + with self._trade_lock: + self.holdings.pop(code, None) + + # 재진입 쿨다운 기록 + self.recently_sold[code] = time.time() + + # WS 구독 해제 (불필요한 데이터 수신 차단) + self.ws_cache.unsubscribe(code) + + logger.info( + f"✅ [매도체결] {name}({code}): " + f"{sell_price:,.4f}원 × {fill['filled_volume']:.8f} " + f"| {profit_pct*100:+.2f}% / {profit_krw:+,.0f}원 | 사유: {reason}" + ) + send_mm( + f"🔴 **매도체결** {name}({code})\n" + f"체결가: {sell_price:,.4f}원 | 사유: {reason}\n" + f"수익률: {profit_pct*100:+.2f}% | 손익: {profit_krw:+,.0f}원 | 수수료: {fee:,.4f}\n" + f"보유: {len(self.holdings)}종목" + ) + return True + + # -------------------------------------------------------- + # 메인 루프 + # -------------------------------------------------------- + + def run(self): + """ + 메인 루프 (★ Ver2: 완전 논블로킹) + + [흐름] + 1. 전체 KRW 마켓 목록 로드 + 2. WebSocket 스레드 시작 (보유 종목 즉시 구독) + 3. 매도 루프 (2초 주기) → WS 캐시 기반, 비동기 Worker + 4. 매수 스캔 (UPBIT_SCAN_INTERVAL_SEC 주기) → REST 캔들, 비동기 Worker + """ + logger.info("🚀 Upbit 단타 트레이더 V2 시작! (비동기 매수/매도)") + + # ── 시작 시 계좌 잔고 조회 ────────────────────────────────────────── + try: + accounts = self.client.get_accounts() + krw_balance = 0.0 + coin_holdings = [] + for acc in accounts: + cur = acc.get("currency", "") + bal = float(acc.get("balance", 0)) + locked = float(acc.get("locked", 0)) + avg_buy = float(acc.get("avg_buy_price", 0)) + if cur == "KRW": + krw_balance = bal + locked + elif bal + locked > 0 and avg_buy > 0: + coin_holdings.append({ + "coin": cur, + "qty": bal + locked, + "avg": avg_buy, + "eval": (bal + locked) * avg_buy, # 매수 기준 평가금 + }) + total_eval = krw_balance + sum(c["eval"] for c in coin_holdings) + logger.info(f"💰 ── 계좌 현황 ──────────────────────────────") + logger.info(f"💰 KRW 가용 잔고 : {krw_balance:>15,.0f} 원") + for c in coin_holdings: + logger.info( + f"💰 {c['coin']:<10} 보유 {c['qty']:.4f} " + f"평단 {c['avg']:,.0f} 평가 {c['eval']:,.0f} 원" + ) + logger.info(f"💰 총 평가금액 : {total_eval:>15,.0f} 원") + logger.info(f"💰 ─────────────────────────────────────────") + except Exception as e: + logger.warning(f"⚠️ 계좌 조회 실패: {e}") + + # KRW 마켓 목록 로드 + all_markets = [ + m["market"] for m in self.client.get_market_list() + if m["market"].startswith("KRW-") + ] + logger.info(f"📋 KRW 마켓 {len(all_markets)}개 로드") + + # WebSocket 시작 (보유 종목 즉시 구독) + self.ws_cache.start(initial_markets=list(self.holdings.keys())) + time.sleep(2) # WS 연결 안정화 대기 + + # last_scan_time을 현재 시각으로 초기화 → 시작 직후 즉시 스캔 방지 + # (scan_interval 만큼 대기 후 첫 스캔 실행) + last_scan_time = time.time() + scan_interval_sec = get_env_int("UPBIT_SCAN_INTERVAL_SEC", 60) + + # Heartbeat: 마지막으로 로그 찍은 시각 (30초마다 대기 상황 출력) + last_heartbeat = time.time() + + # 스캔 전용 스레드 중복 실행 방지 플래그 + _scan_running = False + _scan_lock = threading.Lock() + + def _scan_worker(): + """ + 매수 스캔을 별도 스레드에서 실행 → 메인 매도 감시 루프 블로킹 없음 + scan_buy_candidates 는 243개 마켓 × REST API 호출이라 수십 초 소요됨 + """ + nonlocal _scan_running + try: + with self._trade_lock: + current_holdings = len(self.holdings) + active_buying = len(self._buying) + + if current_holdings + active_buying >= self.max_stocks: + return # 슬롯 없으면 스캔 생략 + + logger.info(f"🔍 매수 스캔 시작 ({len(all_markets)}개 마켓)") + self.db.clear_old_candidates() + candidates = self.scan_buy_candidates(all_markets) + logger.info(f"🎯 후보 {len(candidates)}개 발견") + + top_n = get_env_int("UPBIT_BUY_TOP_N", 2) + for cand in candidates[:top_n]: + code = cand["code"] + with self._trade_lock: + already_hold = code in self.holdings + if already_hold or code in self._buying or code in self._selling: + continue + if len(self.holdings) + len(self._buying) >= self.max_stocks: + break + # 매수 직전 WS 최신가로 가격 갱신 (슬리피지 최소화) + ws_p = self.ws_cache.get_price(code, max_age_sec=5.0) + if ws_p: + cand["price"] = ws_p["price"] + self._buying.add(code) + # ★ 비동기 매수 Worker 스레드 (메인 루프 블로킹 없음) + t = threading.Thread( + target=self._buy_worker, args=(cand,), daemon=True, name=f"Buy-{code}" + ) + t.start() + except Exception as e: + logger.exception(f"❌ 스캔 스레드 예외: {e}") + finally: + with _scan_lock: + _scan_running = False + + try: + while True: + now = time.time() + + # ① 매도 신호 체크 (2초 주기, WS 캐시 기반) — 최우선 실행 + sell_signals = self.check_sell_signals() + for sig in sell_signals: + code = sig["code"] + if code in self._selling: + continue # 이미 매도 처리 중 + self._selling.add(code) + # ★ 비동기 매도 Worker 스레드 생성 (메인 루프 블로킹 없음) + t = threading.Thread( + target=self._sell_worker, args=(sig,), daemon=True, name=f"Sell-{code}" + ) + t.start() + + # ② 매수 스캔 — scan_interval 주기, 별도 스레드로 실행 (비블로킹) + scan_interval_sec = get_env_int("UPBIT_SCAN_INTERVAL_SEC", 60) + if now - last_scan_time >= scan_interval_sec: + last_scan_time = now + with _scan_lock: + if not _scan_running: + # 이전 스캔이 끝나지 않았으면 이번 주기 스킵 (중복 방지) + _scan_running = True + t = threading.Thread(target=_scan_worker, daemon=True, name="Scan") + t.start() + else: + logger.info("⏭️ 이전 스캔 진행 중 — 이번 주기 스킵") + + # ③ Heartbeat — 30초마다 현재 상태 출력 (로그 무음 방지) + heartbeat_interval = get_env_int("UPBIT_HEARTBEAT_SEC", 30) + if now - last_heartbeat >= heartbeat_interval: + last_heartbeat = now + next_scan_in = max(0, scan_interval_sec - (now - last_scan_time)) + with self._trade_lock: + h_count = len(self.holdings) + try: + krw = self.client.get_krw_balance() + except Exception: + krw = -1 + logger.info( + f"💓 [Heartbeat] 보유={h_count}종목 | " + f"KRW={krw:,.0f}원 | " + f"매수중={len(self._buying)} | 매도중={len(self._selling)} | " + f"다음스캔={next_scan_in:.0f}초 후" + ) + + # ④ 2초 대기 (매도 감시 루프 주기) + time.sleep(2) + + except KeyboardInterrupt: + logger.info("⛔ 사용자 종료 요청") + except Exception as e: + logger.exception(f"❌ 메인 루프 예외: {e}") + send_mm(f"🚨 **Upbit V2 봇 비정상 종료**: {e}") + finally: + self.ws_cache.stop() + logger.info("🛑 Upbit 단타 트레이더 V2 종료") + + +# ============================================================ +# 진입점 +# ============================================================ + +if __name__ == "__main__": + trader = UpbitShortTraderV2() + trader.run() diff --git a/upbit_tail_param_search.py b/upbit_tail_param_search.py new file mode 100644 index 0000000..a0e6939 --- /dev/null +++ b/upbit_tail_param_search.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +""" +upbit_tail_param_search.py — 업비트 꼬리잡기 백테스트 파라미터 자동 탐색 (Grid Search) +====================================================================================== +실행: + cd /home/hoon/upbit_bot + python3 upbit_tail_param_search.py + +옵션: + --start 시작일 (기본: 오늘-7일) + --end 종료일 (기본: 오늘) + --mode 탐색 모드: coarse(기본) / fine / full + --timeframe 분봉 단위 (기본: 3) + --top 상위 N개 출력 (기본: 20) + --min_trades 최소 거래 건수 필터 (기본: 3) + --apply 결과 1위를 DB env_config에 자동 적용 (기본: False) + +예시: + python3 upbit_tail_param_search.py --start 2026-03-01 --end 2026-03-09 + python3 upbit_tail_param_search.py --mode fine --timeframe 3 --apply + python3 upbit_tail_param_search.py --mode full --min_trades 5 --top 30 +""" + +import sys, os, json, time, argparse, pymysql, pymysql.cursors +from datetime import datetime, timedelta +from itertools import product + +# upbit_backtest_web 임포트 (같은 디렉토리) +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, SCRIPT_DIR) + +import logging +logging.basicConfig(level=logging.WARNING, format="[%(asctime)s] %(message)s", datefmt="%H:%M:%S") + +from upbit_backtest_web import app # Flask 앱 + +# ──────────────────────────────────────────────────────────────────────────── +# DB 연결 설정 +# ──────────────────────────────────────────────────────────────────────────── +_DB_CFG = dict( + host="192.168.0.141", port=3306, + user="jae", password="1234", + database="upbit_quant_db", + charset="utf8mb4", + autocommit=True, + cursorclass=pymysql.cursors.DictCursor, + connect_timeout=10, +) + +# ──────────────────────────────────────────────────────────────────────────── +# 파라미터 그리드 (코인 시장 특성 반영: 낙폭 크고 수수료 낮음) +# ──────────────────────────────────────────────────────────────────────────── + +GRIDS = { + # 빠른 1차 탐색 (약 480 조합, 2~5분) + "coarse": { + "min_drop_rate": [2.0, 3.0, 4.0, 5.0, 7.0], # 당일 낙폭 최소 (%) + "min_recovery_ratio": [30, 40, 50, 60], # 당일 회복률 최소 (%) + "sl_pct": [2.0, 3.0, 5.0], # 손절 (%) — 코인은 변동성 크므로 넓게 + "tp_pct": [3.0, 5.0, 7.0, 10.0], # 익절 (%) — ATR 기반 목표가 + "tail_ratio_min": [1.0, 1.5, 2.0], # 꼬리/몸통 비율 + }, + # 세밀 2차 탐색 (약 3,240 조합, 10~20분) + "fine": { + "min_drop_rate": [2.0, 3.0, 4.0, 5.0, 7.0], + "min_recovery_ratio": [30, 40, 50, 60], + "sl_pct": [1.5, 2.0, 3.0, 5.0], + "tp_pct": [3.0, 5.0, 7.0, 10.0, 15.0], + "tail_ratio_min": [0.8, 1.0, 1.5, 2.0], + "shoulder_cut_pct": [2.0, 3.0, 5.0], + }, + # 전체 탐색 (약 25,920 조합, 1시간 이상) + "full": { + "min_drop_rate": [1.5, 2.0, 3.0, 4.0, 5.0, 7.0, 10.0], + "min_recovery_ratio": [20, 30, 40, 50, 60, 70], + "sl_pct": [1.5, 2.0, 3.0, 5.0, 7.0], + "tp_pct": [3.0, 5.0, 7.0, 10.0, 15.0, 20.0], + "tail_ratio_min": [0.5, 0.8, 1.0, 1.5, 2.0], + "shoulder_cut_pct": [2.0, 3.0, 5.0, 7.0], + "rsi_threshold": [72, 75, 78, 82], + }, +} + +# 고정값 (탐색에서 제외된 파라미터 — 업비트 코인 특성 반영) +FIXED_DEFAULTS = dict( + rsi_period = 14, + rsi_threshold = 78, # RSI 과열 기준 + max_rec = 80, # 최대 회복 위치 (%) + tail_pct_min = 0.3, # 꼬리 최소 % (%) + shoulder_min_high = 1.5, # 어깨 발동 최소 이익 (%) + shoulder_cut_pct = 3.0, # 어깨 컷 (%) + high_chase_thr = 96.0, # 고점 추격 방지 (%) + slot_money = 100_000, # 코인당 투자금 (원) — 업비트 단위 + fee_rate = 0.05, # 업비트 수수료 0.05% (편도) + cooldown_min = 30, # 쿨다운 (분) + max_hold_hours = 24, # 코인: 장 마감 없음 → 24시간 강제청산 + max_daily = 5, # 코인당 일일 최대 거래횟수 (코인 특성상 주식보다 많게) +) + + +# ──────────────────────────────────────────────────────────────────────────── +# 탐색 실행 +# ──────────────────────────────────────────────────────────────────────────── + +def run_search(start: str, end: str, mode: str, timeframe: int, + top_n: int, min_trades: int, apply_best: bool): + + 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} | 분봉: {timeframe}분") + print("=" * 72) + + results = [] + t0 = time.time() + + with app.test_client() as c: + for i, vals in enumerate(combos): + params = dict(FIXED_DEFAULTS) + params.update(dict(zip(keys, vals))) + params["start"] = start + params["end"] = end + params["timeframe"] = timeframe + + qs = "&".join(f"{k}={v}" for k, v in params.items()) + try: + r = c.get(f"/api/backtest/tail?{qs}") + if r.status_code != 200: + continue + d = json.loads(r.data) + s = d.get("summary", {}) + except Exception: + continue + + 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"], + }) + + 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("⚠️ 유효 조합을 찾지 못했습니다.") + print(" - 기간을 늘리거나 (--start 2026-01-01)") + print(" - min_trades를 낮추거나 (--min_trades 1)") + print(" - upbit_candles DB에 데이터가 있는지 확인하세요.") + return + + results.sort(key=lambda x: x["total_pnl"], reverse=True) + + # ── 결과 출력 ────────────────────────────────────────────────────────── + hdr_keys = list(keys) + col_w = max(len(k) for k in hdr_keys) + 2 + + print(f"\n{'='*72}") + print(f" 🏆 수익 TOP {min(top_n, len(results))} (투자금 {FIXED_DEFAULTS['slot_money']:,}원 기준, 수수료 0.05%)") + print(f"{'='*72}") + + hdr = " ".join(f"{k:>{col_w}}" for k in hdr_keys) + print(f"{hdr} | {'손익(원)':>12} {'승률':>6} {'거래':>5} {'PF':>5} {'보유(분)':>8}") + print("-" * (len(hdr) + 57)) + + 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']:>7.1f}분") + + best = results[0] + bp = best["params"] + + # 파라미터명 → DB 컬럼명 표시 + _disp_map = { + "min_drop_rate": "MIN_DROP_RATE (÷100)", + "min_recovery_ratio": "MIN_RECOVERY_RATIO (÷100)", + "sl_pct": "STOP_LOSS_PCT (음수÷100)", + "tp_pct": "TAKE_PROFIT_PCT (÷100)", + "tail_ratio_min": "TAIL_RATIO_MIN", + "shoulder_cut_pct": "SHOULDER_CUT_PCT (÷100)", + "rsi_threshold": "RSI_OVERHEAT_THRESHOLD", + } + print(f""" +╔══════════════════════════════════════════════╗ +║ 🏆 업비트 꼬리잡기 최적 파라미터 ║ +╠══════════════════════════════════════════════╣""") + for k, v in bp.items(): + label = _disp_map.get(k, k) + print(f"║ {label:<36s}: {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']:>7.1f} 분 ║ +║ 최대 낙폭(MDD): {best['mdd']:>12,.0f} 원 ║ +╚══════════════════════════════════════════════╝""") + + # ── JSON 저장 ────────────────────────────────────────────────────────── + out_dir = os.path.join(SCRIPT_DIR, "param_search_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"upbit_tail_search_{mode}_{ts}.json") + with open(out_path, "w", encoding="utf-8") as f: + json.dump({ + "mode": mode, + "timeframe": timeframe, + "start": start, + "end": end, + "top": results[:top_n], + }, f, ensure_ascii=False, indent=2) + print(f"\n💾 결과 저장: {out_path}") + + if apply_best: + _apply_to_db(bp) + + +# ──────────────────────────────────────────────────────────────────────────── +# DB 자동 적용 +# ──────────────────────────────────────────────────────────────────────────── + +def _apply_to_db(best_params: dict): + """ + 1위 파라미터를 upbit_quant_db env_config에 자동 반영 + - 비율(%) 값을 소수점으로 변환하여 봇이 직접 사용 가능한 형태로 저장 + """ + field_map = { + "min_drop_rate": ("MIN_DROP_RATE", lambda v: str(v / 100)), + "min_recovery_ratio": ("MIN_RECOVERY_RATIO", lambda v: str(v / 100)), + "sl_pct": ("STOP_LOSS_PCT", lambda v: str(-v / 100)), # 음수 저장 + "tp_pct": ("TAKE_PROFIT_PCT", lambda v: str(v / 100)), + "tail_ratio_min": ("TAIL_RATIO_MIN", lambda v: str(v)), + "shoulder_cut_pct": ("SHOULDER_CUT_PCT", lambda v: str(v / 100)), + "rsi_threshold": ("RSI_OVERHEAT_THRESHOLD", lambda v: str(v)), + } + + 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 + + try: + conn = pymysql.connect(**_DB_CFG) + cur = conn.cursor() + + # 최신 env_config row 업데이트 + cur.execute("SELECT id FROM env_config ORDER BY id DESC LIMIT 1") + row = cur.fetchone() + if row: + vals.append(row["id"]) + cur.execute(f"UPDATE env_config SET {', '.join(sets)} WHERE id = %s", vals) + print("\n✅ DB env_config 자동 적용 완료:") + else: + # row가 없으면 새로 생성 + cols = ", ".join(f"`{sets[i].split('`')[1]}`" for i in range(len(sets))) + marks = ", ".join(["%s"] * len(sets)) + cur.execute(f"INSERT INTO env_config ({cols}) VALUES ({marks})", vals) + print("\n✅ DB env_config 새 row 생성 완료:") + + 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:<35s} = {fmt(val)}") + + conn.close() + except Exception as e: + print(f"❌ DB 적용 실패: {e}") + + +# ──────────────────────────────────────────────────────────────────────────── +# 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", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +예시: + python3 upbit_tail_param_search.py + python3 upbit_tail_param_search.py --start 2026-01-01 --end 2026-03-09 + python3 upbit_tail_param_search.py --mode fine --timeframe 3 --apply + python3 upbit_tail_param_search.py --mode full --min_trades 5 --top 30 + +탐색 전 캔들 수집 필수: + → http://localhost:6060 → [캔들 수집] 탭에서 마켓/기간 설정 후 수집 + """, + ) + 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("--timeframe", default=3, type=int, + help="분봉 단위 (3/5/15/60, 기본: 3)") + parser.add_argument("--top", default=20, type=int, + help="상위 N개 출력") + parser.add_argument("--min_trades", default=3, type=int, + help="최소 거래 건수 필터") + parser.add_argument("--apply", action="store_true", + help="1위 결과를 DB env_config에 자동 적용") + args = parser.parse_args() + + # 사전 확인: DB에 캔들 데이터가 있는지 체크 + try: + conn = pymysql.connect(**_DB_CFG) + cur = conn.cursor() + cur.execute( + "SELECT COUNT(*) as cnt FROM upbit_candles WHERE timeframe=%s " + "AND candle_time >= %s AND candle_time <= %s", + [args.timeframe, + args.start.replace("-","") + "0000", + args.end.replace("-","") + "2359"] + ) + cnt = (cur.fetchone() or {}).get("cnt", 0) + conn.close() + + if cnt == 0: + print(f"\n⚠️ [경고] upbit_candles에 {args.timeframe}분봉 데이터가 없습니다!") + print(f" 기간: {args.start} ~ {args.end}") + print(" → http://localhost:6060 → [캔들 수집] 탭에서 먼저 데이터를 수집하세요.\n") + sys.exit(1) + else: + print(f"✅ DB 캔들 확인: {cnt:,}봉 ({args.timeframe}분봉, {args.start}~{args.end})") + except Exception as e: + print(f"⚠️ DB 연결 확인 실패: {e}") + + run_search( + start = args.start, + end = args.end, + mode = args.mode, + timeframe = args.timeframe, + top_n = args.top, + min_trades = args.min_trades, + apply_best = args.apply, + ) + + +if __name__ == "__main__": + main()