From 6d3b0bacc0eb2f89bcd0d292062db1366bb33a51 Mon Sep 17 00:00:00 2001 From: kyu Date: Wed, 20 May 2026 21:33:56 +0900 Subject: [PATCH] Initial commit: Korean stock value-investing AI pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 19개 마이크로서비스 (news-collector, score-engine, ta-engine, dart-collector, aux-signal, us-market, graph-engine, telegram-bot, dashboard-api, kis-api 등) - 가치투자 스코어링 + 10공식 앙상블 보팅 (매직포뮬러/F-Score/Altman/PEG/ 모멘텀/Beneish/GP-A/G-Score/Amihud/BAB) - 뉴스 수집→형태소→임베딩→중복제거→AI분석 파이프라인 - 기술적분석 + GAT 그래프신경망 + 미증시 동조 시그널 Co-Authored-By: Claude Opus 4.7 (1M context) --- ...a21-8bac-63c8c74e8096_20260514_140437.json | 1 + ...0a1-b874-21bbb5027474_20260514_140437.json | 1 + .claudeignore | 12 + .gitignore | 36 + CLAUDE.md | 577 +++ Dockerfile | 8 + aux-signal/Dockerfile | 8 + aux-signal/main.py | 367 ++ aux-signal/requirements.txt | 8 + bareunaapi-main.py | 204 ++ bareunaapi/Dockerfile | 24 + bareunaapi/finance_dict.py | 136 + bareunaapi/main.py | 250 ++ bareunaapi/requirements.txt | 14 + bareunaapi/stock_loader.py | 162 + dart-collector/Dockerfile | 9 + dart-collector/docker-compose-addition.yml | 51 + dart-collector/main.py | 1297 +++++++ dart-collector/requirements.txt | 11 + dashboard-api/Dockerfile | 7 + dashboard-api/auth.py | 59 + dashboard-api/cards.html | 292 ++ dashboard-api/index.html | 1727 +++++++++ dashboard-api/main.py | 2005 +++++++++++ .../migrations/001_users_portfolio.sql | 26 + .../migrations/002_user_approval.sql | 9 + docker-compose-all-phases.yml | 177 + docker-compose.yml | 830 +++++ graph-engine/Dockerfile | 11 + graph-engine/main.py | 635 ++++ graph-engine/requirements.txt | 12 + init-db.sql | 150 + kis-api-main.py | 235 ++ kis-api/Dockerfile | 8 + kis-api/main.py | 902 +++++ kis-api/requirements.txt | 8 + main.py | 269 ++ news-collector/Dockerfile | 8 + news-collector/main.py | 886 +++++ news-collector/requirements.txt | 10 + qdrant-config/config.yaml | 19 + requirements.txt | 10 + score-engine/Dockerfile | 8 + score-engine/main.py | 3155 +++++++++++++++++ score-engine/requirements.txt | 13 + scripts/setup-postgres-local.sh | 146 + scripts/setup.sh | 111 + scripts/status.sh | 51 + setup-all.sh | 84 + stock_loader.py | 162 + ta-engine/Dockerfile | 6 + ta-engine/main.py | 965 +++++ ta-engine/requirements.txt | 10 + telegram-bot/Dockerfile | 7 + telegram-bot/main.py | 499 +++ telegram-bot/requirements.txt | 5 + trading-dashboard.jsx | 268 ++ us-market/Dockerfile | 8 + us-market/main.py | 838 +++++ us-market/requirements.txt | 11 + 60 files changed, 17818 insertions(+) create mode 100644 .backup/n8n/1f94ace6-528e-4a21-8bac-63c8c74e8096_20260514_140437.json create mode 100644 .backup/n8n/669c2fcf-b774-40a1-b874-21bbb5027474_20260514_140437.json create mode 100644 .claudeignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 aux-signal/Dockerfile create mode 100644 aux-signal/main.py create mode 100644 aux-signal/requirements.txt create mode 100644 bareunaapi-main.py create mode 100644 bareunaapi/Dockerfile create mode 100644 bareunaapi/finance_dict.py create mode 100644 bareunaapi/main.py create mode 100644 bareunaapi/requirements.txt create mode 100644 bareunaapi/stock_loader.py create mode 100644 dart-collector/Dockerfile create mode 100644 dart-collector/docker-compose-addition.yml create mode 100644 dart-collector/main.py create mode 100644 dart-collector/requirements.txt create mode 100644 dashboard-api/Dockerfile create mode 100644 dashboard-api/auth.py create mode 100644 dashboard-api/cards.html create mode 100644 dashboard-api/index.html create mode 100644 dashboard-api/main.py create mode 100644 dashboard-api/migrations/001_users_portfolio.sql create mode 100644 dashboard-api/migrations/002_user_approval.sql create mode 100644 docker-compose-all-phases.yml create mode 100644 docker-compose.yml create mode 100644 graph-engine/Dockerfile create mode 100644 graph-engine/main.py create mode 100644 graph-engine/requirements.txt create mode 100644 init-db.sql create mode 100644 kis-api-main.py create mode 100644 kis-api/Dockerfile create mode 100644 kis-api/main.py create mode 100644 kis-api/requirements.txt create mode 100644 main.py create mode 100644 news-collector/Dockerfile create mode 100644 news-collector/main.py create mode 100644 news-collector/requirements.txt create mode 100644 qdrant-config/config.yaml create mode 100644 requirements.txt create mode 100644 score-engine/Dockerfile create mode 100644 score-engine/main.py create mode 100644 score-engine/requirements.txt create mode 100755 scripts/setup-postgres-local.sh create mode 100755 scripts/setup.sh create mode 100755 scripts/status.sh create mode 100644 setup-all.sh create mode 100644 stock_loader.py create mode 100644 ta-engine/Dockerfile create mode 100644 ta-engine/main.py create mode 100644 ta-engine/requirements.txt create mode 100644 telegram-bot/Dockerfile create mode 100644 telegram-bot/main.py create mode 100644 telegram-bot/requirements.txt create mode 100644 trading-dashboard.jsx create mode 100644 us-market/Dockerfile create mode 100644 us-market/main.py create mode 100644 us-market/requirements.txt diff --git a/.backup/n8n/1f94ace6-528e-4a21-8bac-63c8c74e8096_20260514_140437.json b/.backup/n8n/1f94ace6-528e-4a21-8bac-63c8c74e8096_20260514_140437.json new file mode 100644 index 0000000..ee33dc3 --- /dev/null +++ b/.backup/n8n/1f94ace6-528e-4a21-8bac-63c8c74e8096_20260514_140437.json @@ -0,0 +1 @@ +{"id":"1f94ace6-528e-4a21-8bac-63c8c74e8096","name":"🤖 Trading AI 통합 워크플로우","nodes":[{"id": "a01", "name": "🧠 01:00 종목사전학습", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 100], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 1 * * *"}]}}}, {"id": "a02", "name": "종목사전 새로고침", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 100], "parameters": {"method": "POST", "url": "http://bareunaapi:5757/stocks/refresh", "options": {"timeout": 120000}}}, {"id": "a03", "name": "중복캐시 초기화", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [700, 100], "parameters": {"method": "DELETE", "url": "http://bareunaapi:5757/dedup/flush", "options": {"timeout": 30000}}}, {"id": "a04", "name": "형태소재분석", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1000, 100], "parameters": {"method": "POST", "url": "http://bareunaapi:5757/analyze/batch", "options": {"timeout": 120000}, "sendBody": true, "contentType": "json", "specifyBody": "json", "jsonBody": "{\"items\": [{\"title\": \"학습배치시작\"}]}"}}, {"id": "a05", "name": "💾 02:00 워크플로우백업", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 320], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 2 * * *"}]}}}, {"id": "a06", "name": "백업 (호스트 cron으로 대체)", "type": "n8n-nodes-base.noOp", "typeVersion": 1, "position": [400, 320], "parameters": {}}, {"id": "a07", "name": "🔔 06:00 신규상장모니터링", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 540], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 6 * * 1-5"}]}}}, {"id": "a08", "name": "기업목록 갱신", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 540], "parameters": {"method": "POST", "url": "http://dart-collector:8888/collect/corps", "options": {"timeout": 300000}}}, {"id": "a09", "name": "🌅 08:50 장시작브리핑", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 760], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "50 8 * * 1-5"}]}}}, {"id": "a10", "name": "장시작 브리핑 전송", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 760], "parameters": {"method": "POST", "url": "http://score-engine:8686/briefing/send", "options": {"timeout": 60000}}}, {"id": "a11", "name": "📰 매5분 뉴스수집", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 980], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "*/5 8-18 * * 1-5"}]}}}, {"id": "a12", "name": "RSS 수집", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 920], "parameters": {"method": "POST", "url": "http://news-collector:8787/collect/rss", "options": {"timeout": 120000}}}, {"id": "a13", "name": "시장뉴스 수집", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 1040], "parameters": {"method": "POST", "url": "http://news-collector:8787/collect/market", "options": {"timeout": 120000}}}, {"id": "a14", "name": "💹 매5분 주가수집", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 1200], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "*/5 9-15 * * 1-5"}]}}}, {"id": "a15", "name": "주가 수집", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 1200], "parameters": {"method": "POST", "url": "http://kis-api:8585/collect/price", "options": {"timeout": 120000}}}, {"id": "a16", "name": "📊 매30분 기술적분석", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 1420], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "*/30 9-14 * * 1-5"}, {"field": "cronExpression", "expression": "30 15 * * 1-5"}]}}}, {"id": "a17", "name": "TA 전체 분석", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 1420], "parameters": {"method": "POST", "url": "http://ta-engine:8484/analyze/all", "options": {"timeout": 180000}}}, {"id": "a18", "name": "🏆 매1시간 스코어계산", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 1640], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 9-16 * * 1-5"}]}}}, {"id": "a19", "name": "스코어 계산", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 1640], "parameters": {"method": "POST", "url": "http://score-engine:8686/score/calculate", "options": {"timeout": 600000}}}, {"id": "a20", "name": "🔍 매5분 헬스체크", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 1860], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "*/5 * * * *"}]}}}, {"id": "a21", "name": "뉴스수집 체크", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 1860], "parameters": {"method": "GET", "url": "http://news-collector:8787/health", "options": {"timeout": 30000}}}, {"id": "a22", "name": "스코어엔진 체크", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [700, 1860], "parameters": {"method": "GET", "url": "http://score-engine:8686/health", "options": {"timeout": 30000}}}, {"id": "a23", "name": "TA엔진 체크", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1000, 1860], "parameters": {"method": "GET", "url": "http://ta-engine:8484/health", "options": {"timeout": 30000}}}, {"id": "a24", "name": "KIS-API 체크", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1300, 1860], "parameters": {"method": "GET", "url": "http://kis-api:8585/health", "options": {"timeout": 30000}}}, {"id": "a25", "name": "DART 체크", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1600, 1860], "parameters": {"method": "GET", "url": "http://dart-collector:8888/health", "options": {"timeout": 30000}}}, {"id": "a26", "name": "바른API 체크", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1900, 1860], "parameters": {"method": "GET", "url": "http://bareunaapi:5757/health", "options": {"timeout": 30000}}}, {"id": "a27", "name": "📈 16:10 성과추적", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 2080], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "10 16 * * 1-5"}]}}}, {"id": "a28", "name": "추천종목 조회", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 2080], "parameters": {"method": "GET", "url": "http://score-engine:8686/recommendations", "options": {"timeout": 30000}}}, {"id": "a29", "name": "성과 브리핑", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [700, 2080], "parameters": {"method": "POST", "url": "http://score-engine:8686/briefing/send", "options": {"timeout": 30000}}}, {"id": "a30", "name": "📊 17:00 수급수집", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 2300], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 17 * * 1-5"}]}}}, {"id": "a31", "name": "수급 전체 수집", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 2300], "parameters": {"method": "POST", "url": "http://kis-api:8585/collect/full", "options": {"timeout": 600000}}}, {"id": "a32", "name": "🌆 17:30 장마감분석", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 2520], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "30 17 * * 1-5"}]}}}, {"id": "a33", "name": "TA 전체 분석", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 2520], "parameters": {"method": "POST", "url": "http://ta-engine:8484/analyze/all", "options": {"timeout": 180000}}}, {"id": "a34", "name": "스코어 계산", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [700, 2520], "parameters": {"method": "POST", "url": "http://score-engine:8686/score/calculate", "options": {"timeout": 180000}}}, {"id": "a35", "name": "장마감 브리핑", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [1000, 2520], "parameters": {"method": "POST", "url": "http://score-engine:8686/briefing/send", "options": {"timeout": 30000}}}, {"id": "a36", "name": "📋 18:00 DART수집", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 2740], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 18 * * 1-5"}]}}}, {"id": "a37", "name": "재무제표 수집", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 2680], "parameters": {"method": "POST", "url": "http://dart-collector:8888/collect/financials", "options": {"timeout": 1800000}}}, {"id": "a38", "name": "공시 수집", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 2800], "parameters": {"method": "POST", "url": "http://dart-collector:8888/collect/disclosures", "options": {"timeout": 300000}}}, {"id": "a39", "name": "🔄 18:30 DART스코어재계산", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 2960], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "30 18 * * 1-5"}]}}}, {"id": "a40", "name": "스코어 재계산", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 2960], "parameters": {"method": "POST", "url": "http://score-engine:8686/score/calculate", "options": {"timeout": 180000}}}, {"id": "a41", "name": "📊 월09:00 주간리포트", "type": "n8n-nodes-base.scheduleTrigger", "typeVersion": 1.1, "position": [100, 3180], "parameters": {"rule": {"interval": [{"field": "cronExpression", "expression": "0 9 * * 1"}]}}}, {"id": "a42", "name": "추천 랭킹 조회", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [400, 3180], "parameters": {"method": "GET", "url": "http://score-engine:8686/ranking", "options": {"timeout": 30000}}}, {"id": "a43", "name": "주간 브리핑", "type": "n8n-nodes-base.httpRequest", "typeVersion": 4.2, "position": [700, 3180], "parameters": {"method": "POST", "url": "http://score-engine:8686/briefing/send", "options": {"timeout": 30000}}}],"connections":{"🧠 01:00 종목사전학습": {"main": [[{"node": "종목사전 새로고침", "type": "main", "index": 0}]]}, "종목사전 새로고침": {"main": [[{"node": "중복캐시 초기화", "type": "main", "index": 0}]]}, "중복캐시 초기화": {"main": [[{"node": "형태소재분석", "type": "main", "index": 0}]]}, "💾 02:00 워크플로우백업": {"main": [[{"node": "백업 (호스트 cron으로 대체)", "type": "main", "index": 0}]]}, "🔔 06:00 신규상장모니터링": {"main": [[{"node": "기업목록 갱신", "type": "main", "index": 0}]]}, "🌅 08:50 장시작브리핑": {"main": [[{"node": "장시작 브리핑 전송", "type": "main", "index": 0}]]}, "📰 매5분 뉴스수집": {"main": [[{"node": "RSS 수집", "type": "main", "index": 0}, {"node": "시장뉴스 수집", "type": "main", "index": 0}]]}, "💹 매5분 주가수집": {"main": [[{"node": "주가 수집", "type": "main", "index": 0}]]}, "📊 매30분 기술적분석": {"main": [[{"node": "TA 전체 분석", "type": "main", "index": 0}]]}, "🏆 매1시간 스코어계산": {"main": [[{"node": "스코어 계산", "type": "main", "index": 0}]]}, "🔍 매5분 헬스체크": {"main": [[{"node": "뉴스수집 체크", "type": "main", "index": 0}]]}, "뉴스수집 체크": {"main": [[{"node": "스코어엔진 체크", "type": "main", "index": 0}]]}, "스코어엔진 체크": {"main": [[{"node": "TA엔진 체크", "type": "main", "index": 0}]]}, "TA엔진 체크": {"main": [[{"node": "KIS-API 체크", "type": "main", "index": 0}]]}, "KIS-API 체크": {"main": [[{"node": "DART 체크", "type": "main", "index": 0}]]}, "DART 체크": {"main": [[{"node": "바른API 체크", "type": "main", "index": 0}]]}, "📈 16:10 성과추적": {"main": [[{"node": "추천종목 조회", "type": "main", "index": 0}]]}, "추천종목 조회": {"main": [[{"node": "성과 브리핑", "type": "main", "index": 0}]]}, "📊 17:00 수급수집": {"main": [[{"node": "수급 전체 수집", "type": "main", "index": 0}]]}, "🌆 17:30 장마감분석": {"main": [[{"node": "TA 전체 분석", "type": "main", "index": 0}]]}, "TA 전체 분석": {"main": [[{"node": "스코어 계산", "type": "main", "index": 0}]]}, "스코어 계산": {"main": [[{"node": "장마감 브리핑", "type": "main", "index": 0}]]}, "📋 18:00 DART수집": {"main": [[{"node": "재무제표 수집", "type": "main", "index": 0}, {"node": "공시 수집", "type": "main", "index": 0}]]}, "🔄 18:30 DART스코어재계산": {"main": [[{"node": "스코어 재계산", "type": "main", "index": 0}]]}, "📊 월09:00 주간리포트": {"main": [[{"node": "추천 랭킹 조회", "type": "main", "index": 0}]]}, "추천 랭킹 조회": {"main": [[{"node": "주간 브리핑", "type": "main", "index": 0}]]}},"active":true} diff --git a/.backup/n8n/669c2fcf-b774-40a1-b874-21bbb5027474_20260514_140437.json b/.backup/n8n/669c2fcf-b774-40a1-b874-21bbb5027474_20260514_140437.json new file mode 100644 index 0000000..f02bf4b --- /dev/null +++ b/.backup/n8n/669c2fcf-b774-40a1-b874-21bbb5027474_20260514_140437.json @@ -0,0 +1 @@ +{"id":"669c2fcf-b774-40a1-b874-21bbb5027474","name":"📈 AI 예측 성과 추적 (매일 16:10)","nodes":[{"id":"n1","name":"매일 16:10","type":"n8n-nodes-base.scheduleTrigger","typeVersion":1.1,"position":[100,300],"parameters":{"rule":{"interval":[{"field":"cronExpression","expression":"10 16 * * 1-5"}]}}},{"id":"n2","name":"추천종목 랭킹조회","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[350,300],"parameters":{"method":"GET","url":"http://score-engine:8686/recommendations","options":{"timeout":30000}}},{"id":"n3","name":"오늘 성과 브리핑","type":"n8n-nodes-base.httpRequest","typeVersion":4.2,"position":[600,300],"parameters":{"method":"POST","url":"http://score-engine:8686/briefing/send","options":{"timeout":30000}}}],"connections":{"매일 16:10":{"main":[[{"node":"추천종목 랭킹조회","type":"main","index":0}]]},"추천종목 랭킹조회":{"main":[[{"node":"오늘 성과 브리핑","type":"main","index":0}]]}},"active":true} diff --git a/.claudeignore b/.claudeignore new file mode 100644 index 0000000..41157d4 --- /dev/null +++ b/.claudeignore @@ -0,0 +1,12 @@ +*.log +logs/ +data/ +__pycache__/ +.git/ +node_modules/ +*.csv +*.json +*.db +*.sqlite +volumes/ +pg_backup/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..44bdb9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# 비밀/환경 +.env +.env.* +env-additions.txt +*.secret +secrets/ + +# DB 덤프/백업 +pg_backup/ +*.dump +*.sql.gz +*.tar.gz + +# 마운트/모델/캐시 +mnt/ +models/ +**/__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ + +# IDE/OS +.DS_Store +.idea/ +.vscode/ +*.swp +*~ + +# Claude Code 로컬 설정 +.claude/ + +# 로그 +*.log +logs/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..35d9598 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,577 @@ +# Trading AI — Claude Code 프로젝트 가이드 + +> **역할**: 워렌 버핏 스타일 한국 주식 AI 투자 분석 전문가로 행동할 것. +> 가치투자 관점(ROE·영업이익률·부채비율·FCF)을 최우선으로 판단한다. +> 이 파일은 매 대화 시작 시 자동 로드됨 — 파일 탐색 없이 이 내용만으로 작업 시작. + +--- + +## Claude 행동 원칙 + +### 1. 먼저 생각, 그 다음 코딩 +- 가정은 명시적으로 밝힐 것. 불확실하면 질문. +- 해석이 여러 개면 나열하고 고를 것 — 혼자 결정 금지. +- 더 단순한 방법이 있으면 말할 것. 불필요한 복잡성에 반론. + +### 2. 단순함 우선 +- 요청한 것만 구현. 추측성 기능·추상화·유연성 추가 금지. +- "나중에 필요할 수도" 코드 금지. 200줄이 50줄로 가능하면 다시 짤 것. +- 불가능한 시나리오를 위한 에러 핸들링 금지. + +### 3. 정밀한 변경 +- 요청한 부분만 수정. 인접 코드 "개선" 금지. +- 기존 스타일 유지 (내 방식이 달라도). +- 내 변경으로 생긴 불필요한 import/변수/함수만 제거. 기존 dead code는 언급만. + +### 4. 검증 기준 명시 +- 다단계 작업 시 각 단계 완료 조건을 먼저 정의. +- Docker 서비스 변경 → 로그 확인 필수. +- DB 변경 → 쿼리로 확인 필수. + +### 이 프로젝트 특이사항 +- sudo 비밀번호 입력 불가 → sudo 필요 작업은 `! sudo ` 형태로 유저에게 요청. +- n8n 마이그레이션 충돌 이력 있음 → n8n 스키마 변경 시 반드시 백업 먼저. +- NAS fstab은 한 줄 유지 필수 (nfsvers=3). +- 상장폐지 필터(`is_active=true`) 빠지면 추천 결과 오염됨. + +--- + +## 시스템 개요 + +뉴스 수집 → 형태소분석 → 임베딩 → 중복제거 → AI분석 → 재무스코어링 → 기술분석 → 매수/매도 추천 → 텔레그램 알림 + +--- + +## 인프라 + +| 항목 | 값 | +|------|-----| +| 서버 OS | Ubuntu 22.04 | +| GPU | GPU0: RTX 3060 12GB (EXAONE 추론) / GPU1: RTX 3070 8GB (bge-m3 임베딩) | +| NAS | `192.168.0.36:/volume1/trading → /mnt/nas` (nfsvers=3 필수) | +| DB 연결 | `postgres:5432`, DB명 `trading_ai`, user `kyu` | +| 환경변수 | `/home/kyu/trading/.env` | +| Docker | `docker compose` (파일: `/home/kyu/trading/docker-compose.yml`) | +| PostgreSQL 데이터 | `/mnt/nas/postgresql/data` (NAS 저장) | + +--- + +## 서비스 목록 + +| 서비스 | 컨테이너 | 포트 | 내부IP | 역할 | +|--------|---------|------|--------|------| +| news-collector | trading-news-collector | 8787 | 172.30.0.16 | 뉴스수집+형태소+임베딩+Ollama분석 | +| bareunaapi | trading-bareunaapi | 5757 | 172.30.0.12 | 한국어 형태소분석 FastAPI 래퍼 | +| bareun | trading-bareun | 5656/9902 | 172.30.0.15 | 바른 NLP gRPC 서버 | +| dart-collector | trading-dart-collector | 8888 | 172.30.0.17 | DART 공시/재무제표 수집 | +| score-engine | trading-score-engine | 8686 | 172.30.0.19 | 종합 투자 스코어 계산+텔레그램 | +| ta-engine | trading-ta-engine | 8484 | 172.30.0.23 | 기술적분석 (MA/RSI/MACD/볼밴/스토캐스틱) | +| dashboard-api | trading-dashboard-api | 8989 | 172.30.0.22 | 대시보드 REST API | +| kis-api | trading-kis-api | 8585 | 172.30.0.18 | 네이버모바일 주가수집 + 매매시그널 | +| ollama | trading-ollama | 11434 | 172.30.0.13 | EXAONE 3.5 7.8B 추론(GPU0) + bge-m3 임베딩(GPU1) | +| n8n | trading-n8n | 5678 | 172.30.0.20 | 워크플로우 자동화 | +| n8n-worker | trading-n8n-worker | - | 172.30.0.21 | n8n Queue Worker | +| postgres | trading-postgres | 5432 | - | 메인 DB | +| redis | trading-redis | 6379 | 172.30.0.10 | 캐시/중복제거 | +| qdrant | trading-qdrant | 6333 | 172.30.0.11 | 뉴스 벡터 유사도 필터 | +| aux-signal | trading-aux-signal | 8282 | 172.30.0.25 | 보조 데이터: 네이버 컨센서스·기관/외국인 수급 + ECOS 매크로(USD/KRW, 국고채10년) | +| us-market | trading-us-market | 8383 | 172.30.0.24 | 미증시→한국 동조 시그널 (섹터ETF 14개 ±5점, 개별 페어 회귀 ±10점, 자동 페어발굴) | +| graph-engine | trading-graph-engine | 9090 | 172.30.0.27 | GAT 그래프 신경망 (12피처 노드 × 가격상관/섹터/뉴스공기 엣지) → graph_score | +| telegram-bot | trading-telegram-bot | - | 172.30.0.26 | 텔레그램 명령 처리 (/buy /sell /stock /deep /market) | + +--- + +## 파일 구조 & 핵심 함수 + +### `/home/kyu/trading/news-collector/main.py` (21KB) +뉴스 수집 + AI 분석 파이프라인. 포트 8787. + +| 함수/클래스 | 역할 | +|------------|------| +| `class S` | 전역 상태 (DB pool, Redis, Qdrant client) | +| `nhash(title, url)` | SHA256[:16] 뉴스 중복 해시 | +| `is_korean(text)` | 한글 포함 여부 체크 | +| `parse_rss_date(date_str)` | RSS pubDate → ISO timestamp | +| `@app.post("/collect/market")` | 네이버 금융 메인 뉴스 수집 | +| `@app.post("/collect/stocks")` | 종목별 네이버 뉴스 수집 | +| `@app.post("/collect/rss")` | 18개 RSS 피드 수집 | +| `@app.get("/sources")` | RSS 소스 목록 | + +**파이프라인 순서** (함수 내부): +1. 바른API `/analyze` → 형태소분석 + 종목감지 +2. Ollama `bge-m3` → 1024차원 임베딩 +3. Qdrant → 코사인 유사도 ≥0.92 중복 제거 +4. Ollama `exaone3.5:7.8b` (GPU0) → 호재/악재/중립 + intensity(1~5) + catalyst분류 +5. PostgreSQL `news_analysis` 저장 + +**EXAONE 프롬프트**: 버핏 관점 (실적/수주/배당/리스크/모멘텀/기타 catalyst 분류) + +**RSS 소스 (28개)**: 한국경제, 매일경제, 머니투데이, 이데일리, 연합뉴스, 조선비즈, 헤럴드경제, 아시아경제, 파이낸셜뉴스, SBS Biz, 뉴스1, 뉴시스 등 + 네이버금융 직접 크롤링 + +**스케줄러**: +- 평일 RSS: 8-18시 5분마다 (`rss_weekday`) +- 주말 RSS: 8-22시 15분마다 (`rss_weekend`) — 누적 학습용 +- 평일 마켓: 9-17시 10분마다 (`market`, 네이버 금융 메인) +- raw 분석: 24시간 30분마다 200건 batch (`process_raw`) — 백로그 소화용으로 확장 +- 주간 raw 백필: 일요일 02:00 전체 종목 × 50페이지 (`historical_raw_weekly`) + +**수집/분석 분리** (대규모 백필용): +- `POST /collect/historical-raw?count=0&max_pages=100` — raw만 빠르게 수집 (LLM 스킵) +- `POST /process/raw?batch_size=200` — news_raw → news_analysis 점진 분석 +- `GET /raw/stats` — 백필 진행률 (total/unprocessed/processed) + +--- + +### `/home/kyu/trading/bareunaapi/main.py` (9.5KB) +한국어 형태소분석 FastAPI 래퍼. 포트 5757. + +| 함수/클래스 | 역할 | +|------------|------| +| `class AppState` | bareun gRPC stub + Redis + 종목사전 | +| `class AnalyzeRequest / AnalyzeResponse` | 분석 요청/응답 모델 | +| `news_hash(title, url)` | Redis 중복제거용 해시 | +| `extract_morphemes(text)` | 바른API → 명사 추출 | +| `extract_stocks(text)` | 텍스트에서 종목코드 감지 | +| `build_filtered(nouns, stocks)` | 필터링된 텍스트 구성 | +| `scan_finance_terms(text)` | finance_dict.py 금융용어 매칭 | +| `_analyze(req)` | 핵심 분석 로직 | +| `@app.post("/analyze")` | 단건 분석 | +| `@app.post("/analyze/batch")` | 배치 분석 | +| `@app.get("/stocks")` | 로드된 종목 목록 | +| `@app.post("/stocks/refresh")` | KRX→Naver 종목 새로고침 | +| `@app.delete("/dedup/flush")` | Redis 중복제거 캐시 초기화 | + +**관련 파일**: +- `bareunaapi/finance_dict.py` — 주식/금융 전문 용어 사전 +- `bareunaapi/stock_loader.py` — KRX→Naver 폴백, ~3768 종목 로드 + +--- + +### `/home/kyu/trading/dart-collector/main.py` (34KB) +DART 공시/재무제표 수집. 포트 8888. + +| 함수/클래스 | 역할 | +|------------|------| +| `class Stats` | 수집 통계 | +| `get_corp_code(stock_code)` | 종목코드 → DART corp_code 변환 | +| `get_corp_name(stock_code)` | 종목코드 → 기업명 | +| `calc_financial_ratios(key_items, prev_revenue)` | ROE/영업이익률/부채비율/FCF/매출성장률 계산 | +| `@app.post("/collect/disclosures")` | 공시 수집 | +| `@app.post("/collect/financials")` | 재무제표 수집 (300개 종목) | +| `@app.post("/collect/major")` | 주요 대형주 수집 | +| `@app.post("/collect/corps")` | DART 기업목록 갱신 | +| `@app.get("/corps")` | 기업목록 조회 | +| `@app.get("/corps/{stock_code}")` | 특정 기업 조회 | +| `@app.get("/financials/{stock_code}")` | 종목 재무데이터 | +| `@app.get("/stats")` | 수집 통계 | + +--- + +### `/home/kyu/trading/score-engine/main.py` (27KB) +종합 투자 스코어 계산 + 텔레그램 알림. 포트 8686. + +| 함수/클래스 | 역할 | +|------------|------| +| `get_recommendation(score)` | 점수→등급 변환 | +| `calc_fundamental_score(fin, per, pbr)` | 재무점수 산출 (-100~100) | +| `calc_magic_formula(fin, market_cap)` | 그린블라트 매직 포뮬러 (ROC + EY) 0~30점 | +| `calc_piotroski_score(curr, prev)` | 피오트로스키 F-Score 7신호 → -15~+15 | +| `calc_altman_z(fin, market_cap)` | 알트만 Z-Score 단순화 → 매수/매도/관망 신호 | +| `calc_peg(curr, prev, per)` | 린치 GARP — PER/이익성장률 → 신호 | +| `calc_momentum(conn, code)` | AQR 12-1개월 가격 모멘텀 → 신호 | +| `calc_beneish_simplified(curr, prev)` | Beneish M-Score 단순화 (분식 의심도) | +| `aggregate_signals(signals)` | 6공식 보팅 다수결 → (요약, 카운트) | +| `calc_trend_score(conn, code)` | 5년 ROE 일관성·추세 -30~+30 | +| `calc_dcf(fin, market_cap)` | 간이 DCF 내재가치 + 안전마진 | +| `calc_earnings_quality(fin)` | CFO/영업이익 비율 (분식의심 패널티) | +| `is_value_investable(fin, per, pbr, market_cap)` | 버핏 필터 통과 여부 | +| `@app.post("/score/calculate")` | 종목 점수 계산 실행 | +| `@app.post("/briefing/send")` | 텔레그램 브리핑 전송 | +| `@app.get("/ranking")` | 전체 종목 랭킹 | +| `@app.get("/recommendations")` | 추천 종목 목록 | +| `@app.get("/stock/{code}")` | 특정 종목 점수 상세 | +| `@app.get("/backtest")` | 추천 성과 백테스트 (수익률/승률/샤프/MDD/알파) | +| `@app.post("/learn-weights")` | 공식별 신호 → 7d 수익률 회귀로 가중치 학습 | +| `@app.get("/learn-weights")` | 현재 적용 중인 공식 가중치 조회 | +| `@app.post("/learn-pricing")` | D+E: 점수→30d 수익률 선형회귀 + Random Forest 학습 | +| `@app.get("/predict-price/{code}")` | 학습된 모델로 종목 30일 후 예상 수익률·가격 | +| `@app.get("/sector/concentration")` | 섹터 집중도 + 30% 초과 경고 | + +**스코어링 공식 (실제 가중치)**: +``` +종합점수 = 펀더멘털통합×0.24 + 뉴스(catalyst가중)×0.18 + 기술×0.15 + + DART공시×0.10 + 외국인수급×0.14 + 공매도×0.06 + + 가격모멘텀×0.03 + DCF안전마진×0.10 + + 시장레짐 보정(±5~10) + + 앙상블 보팅(±18) ← 6공식 매수/매도 합 × 학습가중치 +``` + +**10공식 앙상블 보팅** — 각 공식이 독립 신호 발신, 학습 가중치로 결합: +| 공식 | 출처/논문 | 데이터 | 매수 신호 | +|------|----------|--------|----------| +| 매직포뮬러 | Greenblatt | ROC + EY | magic_score ≥ 20 | +| F-Score | Piotroski 2000 | 7신호 (전년 대비) | f_score ≥ 6 | +| 알트만 Z | Altman 1968 | 6.72×ROA + 1.05×(시총/부채) | Z ≥ 2.6 | +| PEG | Lynch GARP | PER / 이익성장률 | PEG ≤ 1.5 | +| 12-1 모멘텀 | AQR (Carhart 1997) | (P_-21 / P_-252) - 1 | ≥ 10% | +| Beneish | Beneish 1999 | TATA·SGI·CFO/NI | 의심도 < 50 + CFO/NI > 1 | +| **GP/A** | Novy-Marx 2013 | 영업이익/총자산 (대체) | ≥ 15% | +| **G-Score** | Mohanram 2005 | 5신호 vs 섹터 중앙값 | ≥ 4 | +| **Amihud** | Amihud 2002 | avg(\|return\|/거래대금) | ≥ 100 (소형 알파) | +| **베타(BAB)** | Frazzini-Pedersen 2014 | 종목 vs KOSPI 60일 회귀 | β < 0.7 (저베타 알파) | + +**학습 가중치** (`weight_config` 테이블): +- `POST /learn-weights?days=90` — 백테스트로 공식별 매수vs매도 그룹 7d 수익률 차이(edge) 측정 +- edge가 큰 공식일수록 가중치 ↑ (정규화 후 합 = 6, 균등 시 각 1.0) +- 다음 점수 계산부터 자동 적용. 표본 부족 시 default(균등 1.0) + +**펀더멘털통합** = `clip(buffett_score + trend_score + earnings_quality + magic_score + f_score_adj, -100, 100)` +- `buffett_score`: ROE / 영업이익률 / 부채비율 / 매출성장 / PER / PBR / FCF / 배당 +- `trend_score`: 5년 ROE 일관성·추세 (-30~+30) +- `earnings_quality`: CFO/영업이익 ≥1 가산, <0.7 분식의심 패널티 +- `magic_score`: ROC(영업이익/총자산) + EY(영업이익/EV) 임계값 합산 (0~30) +- `f_score_adj`: F-Score ≥6 +15, 5 +8, 4 +3, ≤2 -15 (가치함정 회피) + +**매직 포뮬러 임계값** (한국 시장 보정): +- ROC: ≥25% +15 / ≥15% +10 / ≥8% +5 +- EY: ≥15% +15 / ≥10% +10 / ≥6% +5 + +**피오트로스키 F-Score 7신호** (현재/전년 사업보고서 비교, 9중 2개는 데이터 부재로 생략): +1. ROA(NI/총자산) > 0 2. CFO > 0 3. ΔROA > 0 4. CFO > NI +5. Δ부채비율 < 0 6. Δ영업이익률 > 0 7. Δ자산회전율 > 0 + +**추천 등급** (점수 + 다수공식 동의 강제): +- 강력매수: 점수 ≥70 AND 매수보팅 ≥3 +- 매수관심: 점수 ≥40 AND 매수보팅 ≥1 AND 매도보팅 <2 +- 매도관심: 점수 ≤-30 OR 매도보팅 ≥3 +- 강력매도: 점수 ≤-60 OR 매도보팅 ≥4 +- 관망: 그 외 + +**버핏 가치투자 필터** (`is_value_investable`): +- `operating_profit > 0` 영업적자 제외 +- ROE ≥ 10% +- 부채비율 ≤ 200% +- PER ≤ 60 +- 시총 ≥ 100억 +- `dart_corps.is_active=true` (상장폐지 제외) + +--- + +### `/home/kyu/trading/ta-engine/main.py` (37KB) +기술적 분석 엔진. 포트 8484. + +| 함수/클래스 | 역할 | +|------------|------| +| `_ema_series(values, period)` | EMA 계산 | +| `_ma(closes, n)` | 단순이동평균 | +| `_rsi(closes, period=14)` | RSI | +| `_macd(closes)` | MACD (12/26/9) | +| `_bollinger(closes, period=20)` | 볼린저밴드 | +| `_stochastic(highs, lows, closes, period=14)` | 스토캐스틱 K/D | +| `_vol_ratio(volumes, period=20)` | 거래량비율 | +| `calc_indicators(ohlcv)` | 전체 지표 계산 | +| `calc_tech_score(ind)` | 기술점수 (-100~100) | +| `calc_price_targets(price, ind, sig)` | 목표가/손절가 계산 | +| `analyze_position(price, buy_price, qty, ...)` | 포지션 분석 | +| `@app.get("/technical/{code}")` | 종목 기술분석 | +| `@app.get("/ranking")` | 기술점수 랭킹 | +| `@app.get("/buy-candidates")` | 매수 후보 | +| `@app.post("/analyze/all")` | 전체 종목 분석 | +| `@app.post("/analyze/{code}")` | 특정 종목 분석 | +| `@app.post("/position")` | 포지션 분석 | +| `@app.get("/report/{code}")` | 종합 리포트 | + +**목표가 계산**: +- 진입가: `min(현재가, MA20×0.99)` +- T1: `max(볼밴상단, 현재가×1.05)` +- T2: `max((현재가+52주고가)/2, 현재가×1.10)` +- T3: `max(52주고가×0.97, 현재가×1.20)` +- 손절가: `min(MA60×0.98, 현재가×0.95)` + +--- + +### `/home/kyu/trading/dashboard-api/main.py` (19KB) +대시보드 REST API. 포트 8989. + +| 엔드포인트 | 역할 | +|-----------|------| +| `GET /api/summary` | 전체 요약 통계 | +| `GET /api/recent` | 최근 뉴스 분석 | +| `GET /api/ranking` | 종합 랭킹 | +| `GET /api/recommendations` | 추천 종목 | +| `GET /api/avoid` | 회피 종목 | +| `GET /api/signals` | 매매 시그널 | +| `GET /api/technical/{code}` | 기술분석 데이터 | +| `GET /api/buy-candidates` | 매수 후보 | +| `GET /api/alerts` | 알림 목록 | +| `GET /api/timeline` | 타임라인 | +| `GET /api/stock/{code}` | 종목 상세 | +| `GET /api/search` | 종목 검색 | +| `POST /api/position` | 포지션 등록 | +| `GET /api/report/{code}` | 종목 리포트 | +| `GET /api/fundamentals` | 전체 재무데이터 | +| `GET /api/fundamentals/{code}` | 종목 재무데이터 | +| `GET /api/name/{code}` | 종목명 조회 | +| `GET /` | 대시보드 HTML | + +--- + +### `/home/kyu/trading/kis-api/main.py` (11KB) +네이버 모바일 주가수집 + 매매시그널. 포트 8585. + +| 함수/엔드포인트 | 역할 | +|---------------|------| +| `class Stats` | 수집 통계 | +| `calc_signal(p, news_sc, dart_sc)` | 뉴스+공시점수 → 매매시그널 | +| `GET /price/{code}` | 단일 종목 주가 | +| `GET /prices` | 전체 주가 목록 | +| `GET /signals` | 전체 시그널 | +| `GET /signals/{code}` | 종목별 시그널 | +| `POST /collect` | 주가 수집 실행 | + +**Redis DB 할당**: +- db=0: n8n Queue +- db=1: bareunaapi 중복제거 +- db=3: kis-api/score-engine (`price:{code}`, `prices:last_update`) +- db=4: news-collector (`news:naver:{hash}`) +- db=5: ta-engine (`ta:{code}`) + +--- + +### `/home/kyu/trading/aux-signal/main.py` +보조 데이터 수집. 포트 8282. + +| 엔드포인트 | 역할 | +|-----------|------| +| `POST /collect/naver` | 네이버 integration API → 컨센서스 + 기관/외국인 일별 수급 | +| `POST /collect/macro` | 한국은행 ECOS → USD/KRW 환율, 국고채 10년 | +| `GET /consensus/{code}` | 종목 컨센서스 (목표가/투자의견) | +| `GET /flow/{code}` | 기관/외국인 일별 순매수 | +| `GET /macro/latest` | 최신 매크로 지표 | +| `GET /macro/{indicator}` | 지표별 시계열 | + +스케줄: 영업일 18:00 네이버 수급/컨센서스, 08:30 ECOS 매크로. + +--- + +### `/home/kyu/trading/us-market/main.py` +미국증시 동조 시그널. 포트 8383. + +| 엔드포인트 | 역할 | +|-----------|------| +| `POST /collect` | Finnhub/AlphaVantage → 미국 ETF·개별주 가격 | +| `POST /collect/backfill` | 신규 페어용 과거 데이터 백필 | +| `POST /collect/yfinance-backfill` | yfinance 폴백 백필 | +| `POST /signal/calculate` | 페어 회귀로 한국 종목별 미증시 시그널 산출 | +| `GET /signal/{kr_code}` | 한국 종목 KR 코드별 시그널 | +| `GET /signal/latest` | 전체 종목 최신 시그널 | +| `GET /pairs` | 등록된 KR↔US 페어 | +| `POST /pairs/recalc-beta` | 60일 회귀 베타 재계산 | +| `POST /pairs/discover` | KOSPI200 × S&P500 상관 자동 발굴 | +| `GET /etfs` / `GET /etfs/{etf}/latest` | 섹터 ETF 현황 | + +**기여 점수**: +- 섹터 ETF 동조 (SOXX/XBI/LIT 등 14개) → 같은 한국 섹터 ±5점 +- 개별 페어 60일 회귀 베타 (NVDA↔SK하이닉스 등) → ±10점 + +스케줄: 매일 KST 07:30 수집, 08:00 시그널 계산. + +--- + +### `/home/kyu/trading/graph-engine/main.py` +GAT 그래프 신경망 (PyTorch). 포트 9090. + +| 엔드포인트 | 역할 | +|-----------|------| +| `POST /graph/build` | 노드/엣지 재구성 | +| `POST /train` | 학습 (6mo rolling) | +| `POST /predict` | 전체 추론 → `stock_scores.graph_score` | +| `GET /predict/{code}` | 종목별 예측 값 | +| `GET /status` | 모델/학습 상태 | + +- **노드**: 활성 종목, 12피처 (1d/5d/20d 수익률, vol_ratio, RSI, tech_score, ROE, 영업이익률, 부채비율, 7d 뉴스, 미증시 overnight, log_mcap) +- **엣지**: ① 60일 가격 상관 |corr|≥0.4 ② 동일 섹터 ③ 뉴스 공기 ≥3회 +- **스케줄**: 일요 06:00 학습 / 매일 08:30 추론 +- **모델 저장**: `/mnt/nas/models/graph/` + +--- + +### `/home/kyu/trading/telegram-bot/main.py` +텔레그램 명령 처리 봇 (python-telegram-bot). + +| 명령 | 역할 | +|------|------| +| `/start /help` | 도움말 | +| `/buy /buys` | 강력매수/매수관심 톱 N | +| `/sell /sells` | 매도관심/강력매도 톱 N | +| `/stock <코드>` | 종목 점수·신호 요약 | +| `/deep <코드>` | RAG+EXAONE 심층분석 결과 | +| `/market` | 시장 요약(섹터·매크로) | + +권한: `TELEGRAM_CHAT_ID` 일치 채팅만 응답. + +--- + +## 데이터베이스 스키마 (PostgreSQL) + +### `news_analysis` +``` +id, title, url, source, published_at, hash(16자 UNIQUE) +sentiment(호재/악재/중립), intensity(1~5), primary_stock +affected_stocks(JSONB), reason, investment_action(매수관심/매도관심/관망) +keywords(JSONB), stock_names(JSONB), stock_codes(JSONB) +similar_count, catalyst, analyzed_at, created_at +``` + +### `stock_technical` +``` +stock_code(UNIQUE), stock_name, price +ma5, ma20, ma60, ma120, rsi, macd, macd_signal, macd_hist +bb_upper, bb_mid, bb_lower, pct_b, stoch_k, stoch_d +vol_ratio, tech_score(-100~100), signal(매수/매도/관망) +obv, obv_trend, vwap20, ichimoku(JSONB) ← 새 지표 +signals(JSONB), targets(JSONB), analyzed_at +``` + +### `dart_corps` +``` +stock_code(PK), corp_code, corp_name, modify_date, is_active +※ is_active=true 필터 필수 (상장폐지 제외) +``` + +### `dart_financials` +``` +stock_code, corp_code, corp_name, bsns_year, reprt_code +revenue, operating_profit, net_income +total_assets, total_liabilities, total_equity, operating_cashflow +roe, operating_margin, net_margin, debt_ratio, revenue_growth, fcf_ratio +UNIQUE(stock_code, bsns_year, reprt_code) +※ F-Score는 전년 11011 사업보고서 필요 → years≥2 백필 +``` + +### `stock_scores` (주요 컬럼) +``` +stock_code, score_date, total_score, recommendation +news_score, dart_score, technical_score, foreign_score, short_score, price_score +trend_score, intrinsic_value, margin_of_safety, earnings_quality +magic_score, f_score, roc_pct, earnings_yield_pct +altman_z, peg, momentum_pct, beneish_score +gpa_pct, g_score, amihud_illiq, market_beta ← 학술 논문 기반 4개 +signals(JSONB), buy_votes, sell_votes +position_size_pct, volatility_60d, market_regime_adj, sector +top_reasons, UNIQUE(stock_code, score_date) +``` + +### `weight_config` +``` +config_date(PK), weights(JSONB), period_days, sample_size +※ 최신 row가 calculate_daily_scores에서 자동 로드 → ensemble_bonus 가중 +``` + +### `pricing_model` (D+E 학습 결과 저장) +``` +model_date(PK), linear_coef, linear_intercept, linear_r2, +rf_features(JSONB), rf_r2, sample_size, period_days, created_at +※ /learn-pricing 호출 시 갱신, /predict-price 호출 시 최신 row 사용 +``` + +### `news_raw` (수집/분석 분리용 임시 저장소) +``` +id, stock_code, title, url, url_hash(UNIQUE), source, +published_at_text, collected_at, processed(BOOL), processed_at +※ 크롤러는 raw만 빠르게 저장 → 야간 cron(process_raw)이 batch로 LLM 분석 +``` + +### 뷰 +- `v_recent_signals` — 최근 24시간 호재/악재 (intensity DESC) +- `v_stock_news_count` — 종목별 7일 뉴스 감성 카운트 + +--- + +## 환경변수 키 목록 (.env) + +``` +POSTGRES_HOST=postgres POSTGRES_PORT=5432 POSTGRES_DB=trading_ai POSTGRES_USER=kyu +REDIS_MAX_MEMORY=2gb REDIS_MAXMEMORY_POLICY=allkeys-lru +OLLAMA_NUM_PARALLEL=4 (EXAONE→GPU0, bge-m3→GPU1 자동 분배) +QDRANT_COLLECTION=news_vectors QDRANT_VECTOR_SIZE=1024 +TELEGRAM_CHAT_ID=8690666445 +KIS_IS_PAPER=true (모의투자 모드) +ECOS_API_KEY=... (한국은행 매크로 — ecos.bok.or.kr/api 무료) +``` + +--- + +## 자주 쓰는 명령어 + +```bash +# 전체 상태 +docker compose ps + +# 특정 서비스 로그 (실시간) +docker logs trading-news-collector --tail 50 -f +docker logs trading-score-engine --tail 50 -f +docker logs trading-n8n --tail 30 + +# 재빌드+재배포 +docker compose build && docker compose up -d + +# PostgreSQL 쿼리 (DB명: trading_ai, user: kyu) +docker exec trading-postgres psql -U kyu -d trading_ai -c "SELECT ..." + +# Redis +source /home/kyu/trading/.env && docker exec trading-redis redis-cli -a $REDIS_PASSWORD + +# n8n 마이그레이션 확인 +docker exec trading-postgres psql -U kyu -d trading_ai -c "SELECT COUNT(*) FROM n8n.migrations;" + +# 추천 종목 조회 +docker exec trading-postgres psql -U kyu -d trading_ai -c " + SELECT s.stock_code, d.corp_name, s.total_score, s.recommendation, + s.magic_score, s.f_score, s.roc_pct, s.earnings_yield_pct + FROM stock_scores s JOIN dart_corps d ON d.stock_code=s.stock_code + WHERE d.is_active=true AND s.score_date=CURRENT_DATE + ORDER BY s.total_score DESC LIMIT 10;" + +# 다년치 사업보고서 백필 (F-Score / PEG용) +curl -X POST 'http://localhost:8888/collect/financials?count=3000&years=10&annual_only=true' + +# 섹터 정보 채우기 (dart_corps.sector) +curl -X POST http://localhost:8888/collect/sectors + +# 점수 수동 재계산 (전체 활성종목 대상) +curl -X POST http://localhost:8686/score/calculate + +# 백테스트 기반 공식 가중치 학습 +curl -X POST 'http://localhost:8686/learn-weights?days=90' + +# 현재 가중치 조회 +curl -s http://localhost:8686/learn-weights + +# 종목별 6공식 신호 확인 +docker exec trading-postgres psql -U kyu -d trading_ai -c " + SELECT stock_code, stock_name, total_score, signals + FROM stock_scores WHERE score_date=CURRENT_DATE + ORDER BY total_score DESC LIMIT 10;" +``` + +--- + +## 주의사항 + +- **상장폐지 필터**: 모든 추천 쿼리에 `JOIN dart_corps d ON d.stock_code=... WHERE d.is_active=true` 필수 +- **fstab**: NAS 마운트는 반드시 한 줄로 작성, `nfsvers=3` 필수 +- **GPU**: Ollama 단일 컨테이너가 GPU0(EXAONE 추론)·GPU1(bge-m3 임베딩) 동시 사용. `runtime: nvidia` 필요 +- **KIS**: 현재 모의투자 모드 (`KIS_IS_PAPER=true`) +- **바른API**: KRX→Naver 폴백으로 ~3768 종목 로드. 형태소 실패 시 공백분리 폴백 + +--- + +## 현재 이슈 / TODO + +- [ ] fstab NAS 항목이 두 줄로 분리됨 — sudo로 직접 수정 필요 +- [ ] n8n 워크플로우 재구성 필요 (재부팅 후 소실) +- [x] vLLM 제거 → Ollama 단일 운영으로 전환 (EXAONE 3.5 7.8B + bge-m3) +- [x] n8n DB 마이그레이션 오류 수정 완료 (51개 마이그레이션 등록) +- [x] SSL 인증서 자동 갱신 설정 완료 (certbot.timer + deploy hook → nginx reload) + 도메인: al/cla/n8/pns/tr.kyleyang.co.kr, 훅: /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..940afa8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8787 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8787", "--workers", "1", "--log-level", "info"] diff --git a/aux-signal/Dockerfile b/aux-signal/Dockerfile new file mode 100644 index 0000000..a99f5e9 --- /dev/null +++ b/aux-signal/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8282 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8282", "--workers", "1", "--log-level", "info"] diff --git a/aux-signal/main.py b/aux-signal/main.py new file mode 100644 index 0000000..b8659de --- /dev/null +++ b/aux-signal/main.py @@ -0,0 +1,367 @@ +""" +Aux Signal Service (port 8282, 172.30.0.25) + +외부 보조 데이터 수집: +- 네이버 종목 integration API → 컨센서스 + 기관/외국인 일별 수급 +- 한국은행 ECOS API → USD/KRW 환율, 국고채 10년 금리 +""" +import os +import asyncio +import json +from datetime import date, datetime, timedelta +from typing import Optional, List + +import asyncpg +import structlog +import httpx +from fastapi import FastAPI, Query, BackgroundTasks +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from pytz import timezone + +# ───────────────────────────────────────────────────────────── +# 설정 +# ───────────────────────────────────────────────────────────── +PG = { + "host": os.getenv("POSTGRES_HOST", "postgres"), + "port": int(os.getenv("POSTGRES_PORT", 5432)), + "database": os.getenv("POSTGRES_DB", "trading_ai"), + "user": os.getenv("POSTGRES_USER", "kyu"), + "password": os.getenv("POSTGRES_PASSWORD", ""), +} +KST = timezone("Asia/Seoul") +ECOS_KEY = os.getenv("ECOS_API_KEY", "") + +logger = structlog.get_logger() +app = FastAPI(title="Aux Signal") +pg_pool: Optional[asyncpg.Pool] = None +scheduler = AsyncIOScheduler(timezone=KST) + +# ───────────────────────────────────────────────────────────── +# DDL +# ───────────────────────────────────────────────────────────── +DDL = """ +CREATE TABLE IF NOT EXISTS analyst_consensus ( + stock_code VARCHAR(10) PRIMARY KEY, + target_price BIGINT DEFAULT 0, + recomm_mean DOUBLE PRECISION DEFAULT 0, + create_date DATE, + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS inst_daily_flow ( + stock_code VARCHAR(10), + trade_date DATE, + foreign_net BIGINT DEFAULT 0, + inst_net BIGINT DEFAULT 0, + individual_net BIGINT DEFAULT 0, + foreign_hold_ratio DOUBLE PRECISION DEFAULT 0, + close_price BIGINT DEFAULT 0, + change_amount BIGINT DEFAULT 0, + PRIMARY KEY (stock_code, trade_date) +); +CREATE INDEX IF NOT EXISTS idx_flow_stock ON inst_daily_flow(stock_code, trade_date DESC); + +CREATE TABLE IF NOT EXISTS macro_daily ( + indicator VARCHAR(20), + trade_date DATE, + value DOUBLE PRECISION, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (indicator, trade_date) +); +""" + +# ───────────────────────────────────────────────────────────── +# Helpers +# ───────────────────────────────────────────────────────────── +def _parse_int(v) -> int: + if not v or v in ("-", ""): + return 0 + s = str(v).replace(",", "").replace("+", "").replace(" ", "").strip() + try: return int(s) + except Exception: return 0 + + +def _parse_float(v) -> float: + if not v or v in ("-", ""): + return 0.0 + s = str(v).replace(",", "").replace("%", "").replace("+", "").replace(" ", "").strip() + try: return float(s) + except Exception: return 0.0 + + +# ───────────────────────────────────────────────────────────── +# 네이버 종목 integration API +# 응답: consensusInfo + dealTrendInfos (60일치 일별 매매동향) +# ───────────────────────────────────────────────────────────── +async def fetch_naver(client: httpx.AsyncClient, code: str) -> Optional[dict]: + url = f"https://m.stock.naver.com/api/stock/{code}/integration" + try: + r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15) + if r.status_code != 200: + return None + return r.json() + except Exception as e: + logger.debug("naver.req_err", code=code, err=str(e)) + return None + + +async def save_consensus(conn, code: str, ci: dict): + if not ci: + return False + tp = _parse_int(ci.get("priceTargetMean")) + rm = _parse_float(ci.get("recommMean")) + cd = ci.get("createDate") + cd_date = None + if cd: + try: + cd_date = datetime.strptime(cd, "%Y-%m-%d").date() + except Exception: + cd_date = None + if tp == 0 and rm == 0: + return False + await conn.execute(""" + INSERT INTO analyst_consensus (stock_code, target_price, recomm_mean, create_date) + VALUES ($1, $2, $3, $4) + ON CONFLICT (stock_code) DO UPDATE + SET target_price=$2, recomm_mean=$3, create_date=$4, updated_at=NOW() + """, code, tp, rm, cd_date) + return True + + +async def save_flow(conn, code: str, dt_infos: list) -> int: + saved = 0 + for r in dt_infos: + try: + bz = r.get("bizdate") + if not bz: continue + trade_dt = datetime.strptime(bz, "%Y%m%d").date() + await conn.execute(""" + INSERT INTO inst_daily_flow + (stock_code, trade_date, foreign_net, inst_net, + individual_net, foreign_hold_ratio, + close_price, change_amount) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (stock_code, trade_date) DO UPDATE + SET foreign_net=$3, inst_net=$4, individual_net=$5, + foreign_hold_ratio=$6, close_price=$7, change_amount=$8 + """, code, trade_dt, + _parse_int(r.get("foreignerPureBuyQuant")), + _parse_int(r.get("organPureBuyQuant")), + _parse_int(r.get("individualPureBuyQuant")), + _parse_float(r.get("foreignerHoldRatio")), + _parse_int(r.get("closePrice")), + _parse_int(r.get("compareToPreviousClosePrice"))) + saved += 1 + except Exception as e: + logger.debug("flow.save_err", code=code, err=str(e)) + return saved + + +async def collect_naver_data(count: int = 500): + """시총 상위 N개 종목 컨센서스 + 일별 수급 수집""" + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT DISTINCT ON (d.stock_code) d.stock_code, + COALESCE(p.market_cap, 0) AS mc + FROM dart_corps d + LEFT JOIN stock_prices p ON p.stock_code=d.stock_code + WHERE d.is_active=true + ORDER BY d.stock_code, p.collected_at DESC NULLS LAST + """) + top = sorted(rows, key=lambda r: -r["mc"])[:count] + logger.info("naver.collect_start", count=len(top)) + + cons_saved, flow_saved = 0, 0 + async with httpx.AsyncClient() as client: + for i, row in enumerate(top): + code = row["stock_code"] + j = await fetch_naver(client, code) + if j: + async with pg_pool.acquire() as c2: + if await save_consensus(c2, code, j.get("consensusInfo") or {}): + cons_saved += 1 + s = await save_flow(c2, code, j.get("dealTrendInfos") or []) + flow_saved += s + # 네이버 rate-limit 회피 + if i < len(top) - 1: + await asyncio.sleep(0.3) + logger.info("naver.collect_done", consensus=cons_saved, flow_rows=flow_saved) + return {"consensus": cons_saved, "flow_rows": flow_saved} + + +# ───────────────────────────────────────────────────────────── +# ECOS 매크로 +# 731Y001 → 환율 (0000001=USD/KRW) +# 817Y002 → 시장금리 (010195000=국고채 10년) +# ───────────────────────────────────────────────────────────── +ECOS_BASE = "https://ecos.bok.or.kr/api/StatisticSearch" + +ECOS_SERIES = [ + # (indicator_name, stat_code, item_code1, freq) + ("usdkrw", "731Y001", "0000001", "D"), + ("kor_10y", "817Y002", "010195000", "D"), + ("kor_3y", "817Y002", "010190000", "D"), + ("kospi", "802Y001", "0001000", "D"), +] + + +async def fetch_ecos_series(client: httpx.AsyncClient, stat: str, item: str, + freq: str = "D", days: int = 90) -> list: + if not ECOS_KEY: + return [] + end = date.today().strftime("%Y%m%d") + start = (date.today() - timedelta(days=days)).strftime("%Y%m%d") + url = f"{ECOS_BASE}/{ECOS_KEY}/json/kr/1/200/{stat}/{freq}/{start}/{end}/{item}" + try: + r = await client.get(url, timeout=15) + if r.status_code != 200: + return [] + j = r.json() + return (j.get("StatisticSearch") or {}).get("row", []) or [] + except Exception as e: + logger.warning("ecos.req_err", stat=stat, item=item, err=str(e)) + return [] + + +async def collect_ecos_macro(days: int = 90): + if not ECOS_KEY: + logger.error("ecos.no_key") + return {"saved": 0, "err": "ECOS_API_KEY missing"} + saved = 0 + async with httpx.AsyncClient() as client: + for name, stat, item, freq in ECOS_SERIES: + rows = await fetch_ecos_series(client, stat, item, freq, days) + async with pg_pool.acquire() as conn: + for r in rows: + try: + t = r.get("TIME") + v = r.get("DATA_VALUE") + if not t or not v: + continue + dt = datetime.strptime(t, "%Y%m%d").date() + await conn.execute(""" + INSERT INTO macro_daily (indicator, trade_date, value) + VALUES ($1, $2, $3) + ON CONFLICT (indicator, trade_date) DO UPDATE + SET value=$3 + """, name, dt, float(v)) + saved += 1 + except Exception as e: + logger.debug("ecos.save_err", err=str(e)) + await asyncio.sleep(0.2) + logger.info("ecos.collect_done", saved=saved) + return {"saved": saved} + + +# ───────────────────────────────────────────────────────────── +# 시작/종료 +# ───────────────────────────────────────────────────────────── +@app.on_event("startup") +async def on_start(): + global pg_pool + pg_pool = await asyncpg.create_pool(**PG, min_size=2, max_size=10) + async with pg_pool.acquire() as conn: + await conn.execute(DDL) + + # 네이버 컨센서스/수급: 매일 평일 16:30 (장 마감 후) + scheduler.add_job(collect_naver_data, CronTrigger( + day_of_week="mon-fri", hour=16, minute=30), + id="naver_collect", replace_existing=True) + # ECOS 매크로: 매일 새벽 5시 + scheduler.add_job(collect_ecos_macro, CronTrigger(hour=5), + id="ecos_macro", replace_existing=True) + scheduler.start() + logger.info("aux-signal.started") + + +@app.on_event("shutdown") +async def on_stop(): + if scheduler.running: + scheduler.shutdown() + if pg_pool: + await pg_pool.close() + + +# ───────────────────────────────────────────────────────────── +# REST API +# ───────────────────────────────────────────────────────────── +@app.get("/health") +async def health(): + return {"ok": True, "service": "aux-signal", + "ts": datetime.now(KST).isoformat()} + + +@app.post("/collect/naver") +async def manual_naver(count: int = Query(default=500, ge=10, le=2000), + bg: BackgroundTasks = None): + if bg: + bg.add_task(collect_naver_data, count) + return {"status": "queued", "count": count} + return await collect_naver_data(count) + + +@app.post("/collect/macro") +async def manual_macro(days: int = Query(default=90, ge=7, le=365)): + return await collect_ecos_macro(days) + + +@app.get("/consensus/{code}") +async def get_consensus(code: str): + async with pg_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT * FROM analyst_consensus WHERE stock_code=$1", code) + return dict(row) if row else {"err": "no data"} + + +@app.get("/flow/{code}") +async def get_flow(code: str, days: int = Query(default=30, ge=1, le=90)): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT trade_date, foreign_net, inst_net, individual_net, + foreign_hold_ratio, close_price, change_amount + FROM inst_daily_flow + WHERE stock_code=$1 AND trade_date >= CURRENT_DATE - $2::int + ORDER BY trade_date DESC + """, code, days) + return [dict(r) for r in rows] + + +@app.get("/macro/latest") +async def macro_latest(): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT DISTINCT ON (indicator) indicator, trade_date, value + FROM macro_daily + ORDER BY indicator, trade_date DESC + """) + return {r["indicator"]: {"date": str(r["trade_date"]), + "value": float(r["value"])} + for r in rows} + + +@app.get("/macro/{indicator}") +async def macro_series(indicator: str, days: int = Query(default=30, ge=1, le=365)): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT trade_date, value FROM macro_daily + WHERE indicator=$1 AND trade_date >= CURRENT_DATE - $2::int + ORDER BY trade_date DESC + """, indicator, days) + return [{"date": str(r["trade_date"]), "value": float(r["value"])} + for r in rows] + + +@app.get("/stats") +async def stats(): + async with pg_pool.acquire() as conn: + c = await conn.fetchval("SELECT COUNT(*) FROM analyst_consensus") + f = await conn.fetchrow( + "SELECT COUNT(*) AS rows, COUNT(DISTINCT stock_code) AS codes," + " MAX(trade_date) AS latest FROM inst_daily_flow") + m = await conn.fetchrow( + "SELECT COUNT(*) AS rows, COUNT(DISTINCT indicator) AS indicators," + " MAX(trade_date) AS latest FROM macro_daily") + return {"consensus_stocks": c, + "flow": dict(f) if f else {}, + "macro": dict(m) if m else {}} diff --git a/aux-signal/requirements.txt b/aux-signal/requirements.txt new file mode 100644 index 0000000..38b7ab7 --- /dev/null +++ b/aux-signal/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +asyncpg==0.29.0 +apscheduler==3.10.4 +structlog==24.2.0 +httpx==0.27.0 +orjson==3.10.3 +pytz==2024.1 diff --git a/bareunaapi-main.py b/bareunaapi-main.py new file mode 100644 index 0000000..84c3b2a --- /dev/null +++ b/bareunaapi-main.py @@ -0,0 +1,204 @@ +""" +바른 API FastAPI 서버 v2 +- 서버 시작 시 KRX 전체 종목 동적 로딩 +- 24시간마다 자동 갱신 +- Bareun gRPC 연결 +""" +import asyncio, hashlib, os, time +from contextlib import asynccontextmanager +from typing import Optional +import orjson, redis.asyncio as aioredis, structlog +from bareunpy import Tagger +from fastapi import FastAPI, HTTPException, Response +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, Field +from stock_loader import load_all_stocks, auto_refresh_stocks + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +BAREUN_API_KEY = os.getenv("BAREUN_API_KEY", "") +BAREUN_SERVER_HOST = os.getenv("BAREUN_SERVER_HOST", "bareun") +BAREUN_SERVER_PORT = int(os.getenv("BAREUN_SERVER_PORT", "5656")) +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +REDIS_DB = int(os.getenv("REDIS_DB", "1")) +NEWS_DEDUP_TTL = int(os.getenv("NEWS_DEDUP_TTL", "86400")) + +STOPWORDS = { + "것","수","등","및","또","또한","이","그","저","위","아래","관련","통해", + "위해","대해","따라","때문","이후","이전","현재","최근","지난","올해","내년", + "이번","오늘","어제","내일","이날","같은","다른","많은","더","가장","매우", + "이미","아직","모든","각","전체","일부","국내","해외","글로벌","세계","한국", + "미국","중국","일본","유럽","가운데","한편","다만","특히","실제","여전히", + "앞으로","지속","계속","자체","관계자","측","쪽","곳","점","경우","상황", + "때","중","간", +} +FINANCE_KEYWORDS = { + "실적","매출","영업이익","순이익","적자","흑자","손실","이익","투자","배당", + "자사주","유상증자","합병","인수","M&A","IPO","상장","상폐","거래정지","금리", + "기준금리","환율","달러","반도체","배터리","전기차","수소","AI","인공지능", + "클라우드","바이오","의약품","임상","승인","특허","수주","계약","부도","파산", + "부채","시총","코스피","코스닥","나스닥", +} +START_TIME = time.time() + +class AppState: + tagger: Optional[Tagger] = None + redis: Optional[aioredis.Redis] = None + stock_map: dict[str, str] = {} + stock_count: int = 0 + refresh_task: Optional[asyncio.Task] = None + +state = AppState() + +@asynccontextmanager +async def lifespan(app: FastAPI): + state.stock_map = await load_all_stocks() + state.stock_count = len(state.stock_map) + logger.info("stocks.ready", count=state.stock_count) + state.refresh_task = asyncio.create_task(auto_refresh_stocks(state, 24)) + try: + state.tagger = Tagger(BAREUN_API_KEY, BAREUN_SERVER_HOST, BAREUN_SERVER_PORT) + logger.info("tagger.ok") + except Exception as e: + logger.warning("tagger.failed", error=str(e)) + try: + state.redis = aioredis.Redis(host=REDIS_HOST, port=REDIS_PORT, + password=REDIS_PASSWORD, db=REDIS_DB, decode_responses=True, + socket_connect_timeout=5, retry_on_timeout=True) + await state.redis.ping() + logger.info("redis.ok") + except Exception as e: + logger.error("redis.failed", error=str(e)) + yield + if state.refresh_task: state.refresh_task.cancel() + if state.redis: await state.redis.aclose() + +app = FastAPI(title="바른 API v2", version="2.0.0", lifespan=lifespan) +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) +Instrumentator().instrument(app).expose(app, endpoint="/metrics") + +class AnalyzeRequest(BaseModel): + title: str; content: str = ""; url: str = ""; source: str = ""; published_at: str = "" + +class StockMention(BaseModel): + name: str; code: str; count: int + +class AnalyzeResponse(BaseModel): + hash: str; is_duplicate: bool; stocks: list[StockMention]; keywords: list[str] + filtered_text: str; token_count: int; processing_time_ms: float + +class BatchRequest(BaseModel): + items: list[AnalyzeRequest] + +def news_hash(title, url): + return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16] + +async def is_duplicate(h): + if not state.redis: return False + try: + r = await state.redis.set(f"news:dedup:{h}", "1", ex=NEWS_DEDUP_TTL, nx=True) + return r is None + except: return False + +def extract_morphemes(text): + if not state.tagger or not text.strip(): + return [w for w in text.split() if len(w) >= 2 and w not in STOPWORDS] + try: + return [t for t, p in state.tagger.pos(text) + if p in ("NNG","NNP","SL") and len(t) >= 2 and t not in STOPWORDS] + except: + return [w for w in text.split() if len(w) >= 2 and w not in STOPWORDS] + +def extract_stocks(text): + found = {} + for name, code in state.stock_map.items(): + c = text.count(name) + if c > 0: found[name] = StockMention(name=name, code=code, count=c) + return sorted(found.values(), key=lambda x: x.count, reverse=True) + +def build_filtered(nouns, stocks): + sn = {s.name for s in stocks} + p = [n for n in nouns if n in sn] + f = [n for n in nouns if n in FINANCE_KEYWORDS] + g = [n for n in nouns if n not in STOPWORDS and len(n) >= 2] + return " ".join(list(dict.fromkeys(p + f + g))[:100]) + +def _analyze(req): + text = f"{req.title} {req.content}".strip() + h = news_hash(req.title, req.url) + nouns = extract_morphemes(text) + stocks = extract_stocks(text) + kw = list(dict.fromkeys(n for n in nouns if len(n) >= 2))[:50] + ft = build_filtered(nouns, stocks) + return h, stocks, kw, ft + +@app.get("/health") +async def health(): + tok = state.tagger is not None + rok = False + if state.redis: + try: await state.redis.ping(); rok = True + except: pass + return JSONResponse(content={"status": "ok" if tok else "degraded", + "tagger": "ok" if tok else "unavailable", "redis": "ok" if rok else "error", + "stocks_loaded": state.stock_count, "uptime": round(time.time()-START_TIME,1)}) + +@app.post("/analyze") +async def analyze(req: AnalyzeRequest): + t = time.perf_counter() + h, stocks, kw, ft = _analyze(req) + dup = await is_duplicate(h) + ms = round((time.perf_counter()-t)*1000, 2) + return Response(content=orjson.dumps(AnalyzeResponse( + hash=h, is_duplicate=dup, stocks=stocks, keywords=kw, + filtered_text=ft, token_count=len(ft.split()), processing_time_ms=ms + ).model_dump()), media_type="application/json") + +@app.post("/analyze/batch") +async def analyze_batch(req: BatchRequest): + if len(req.items) > 50: raise HTTPException(400, "최대 50개") + t = time.perf_counter() + results = [] + for item in req.items: + try: + h, stocks, kw, ft = _analyze(item) + dup = await is_duplicate(h) + results.append({"title":item.title,"hash":h,"is_duplicate":dup, + "stocks":[s.model_dump() for s in stocks],"keywords":kw, + "filtered_text":ft,"token_count":len(ft.split())}) + except Exception as e: + results.append({"title":item.title,"error":str(e),"is_duplicate":False}) + ms = round((time.perf_counter()-t)*1000, 2) + dups = sum(1 for r in results if r.get("is_duplicate")) + return Response(content=orjson.dumps({"total":len(results),"duplicates":dups, + "processed":len(results)-dups,"elapsed_ms":ms,"results":results}), + media_type="application/json") + +@app.get("/stocks") +async def stocks_list(): + return JSONResponse(content={"count":len(state.stock_map), + "stocks":[{"name":k,"code":v} for k,v in list(state.stock_map.items())[:500]]}) + +@app.post("/stocks/refresh") +async def refresh(): + m = await load_all_stocks() + if len(m) > 50: + state.stock_map = m; state.stock_count = len(m) + return JSONResponse(content={"status":"ok","count":len(m)}) + raise HTTPException(500, "종목 로딩 실패") + +@app.delete("/dedup/flush") +async def flush(): + if not state.redis: raise HTTPException(503) + keys = await state.redis.keys("news:dedup:*") + if keys: await state.redis.delete(*keys) + return {"deleted": len(keys)} diff --git a/bareunaapi/Dockerfile b/bareunaapi/Dockerfile new file mode 100644 index 0000000..ea05809 --- /dev/null +++ b/bareunaapi/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + curl \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . +RUN mkdir -p /app/logs + +EXPOSE 5757 + +CMD ["python", "-m", "uvicorn", "main:app", \ + "--host", "0.0.0.0", \ + "--port", "5757", \ + "--workers", "4", \ + "--loop", "uvloop", \ + "--http", "httptools", \ + "--log-level", "info"] diff --git a/bareunaapi/finance_dict.py b/bareunaapi/finance_dict.py new file mode 100644 index 0000000..89dd6a6 --- /dev/null +++ b/bareunaapi/finance_dict.py @@ -0,0 +1,136 @@ +""" +한국 주식/금융 전문 용어 사전 +형태소 분석 보완용 — 바른 API가 분리하지 못하는 복합어 및 금융 전문어를 정의 +""" + +# ── 재무제표 / 회계 ───────────────────────────────────────────────────────────── +FINANCIAL_STATEMENT = { + "매출액","매출원가","매출총이익","영업이익","영업손실","영업외수익","영업외비용", + "세전이익","당기순이익","당기순손실","포괄손익","이익잉여금","자본잉여금", + "납입자본","기타자본","비지배지분","총자산","총부채","총자본","유동자산", + "비유동자산","유동부채","비유동부채","현금및현금성자산","단기금융상품", + "매출채권","재고자산","유형자산","무형자산","투자자산","영업활동현금흐름", + "투자활동현금흐름","재무활동현금흐름","잉여현금흐름","FCF","EBITDA","EBIT", + "ROE","ROA","ROI","EPS","BPS","DPS","PER","PBR","PSR","EV/EBITDA","PCR", + "부채비율","유동비율","당좌비율","자기자본비율","이자보상배율","영업이익률", + "순이익률","자산회전율","재고자산회전율","매출채권회전율", +} + +# ── 주식 시장 구조 ─────────────────────────────────────────────────────────────── +MARKET_STRUCTURE = { + "코스피","코스닥","코넥스","K-OTC","유가증권시장","장외시장","선물시장", + "옵션시장","ETF","ETN","ELS","DLS","리츠","SPAC","공모주","유상증자","무상증자", + "감자","합병","분할","인적분할","물적분할","자사주매입","자사주소각","배당", + "중간배당","특별배당","주식배당","현금배당","우선주","보통주","전환사채","CB", + "신주인수권부사채","BW","교환사채","EB","DR","GDR","ADR","상장","상장폐지", + "거래정지","매매정지","관리종목","투자주의","투자경고","투자위험","단기과열", + "공매도","대차거래","프로그램매매","외국인","기관","개인","세력","수급", + "시가총액","거래량","거래대금","52주최고","52주최저","신고가","신저가", +} + +# ── 기술적 분석 지표 ───────────────────────────────────────────────────────────── +TECHNICAL_INDICATORS = { + "이동평균","이평선","골든크로스","데드크로스","MACD","RSI","스토캐스틱", + "볼린저밴드","볼밴","일목균형표","지지","저항","추세선","채널","삼각수렴", + "쐐기형","헤드앤숄더","역헤드앤숄더","더블탑","더블바텀","갭","갭업","갭다운", + "윗꼬리","아래꼬리","망치형","도지","장악형","상승반전","하락반전", + "거래량폭발","거래량급증","음봉","양봉","캔들","봉차트","분봉","일봉","주봉","월봉", + "오버솔드","오버바우트","과매수","과매도","다이버전스","모멘텀", +} + +# ── 기업 이벤트 / 공시 ─────────────────────────────────────────────────────────── +CORPORATE_EVENTS = { + "실적발표","어닝시즌","어닝서프라이즈","어닝쇼크","가이던스","실적전망", + "수주","수주잔고","계약체결","MOU","LOI","업무협약","전략적제휴","조인트벤처", + "JV","M&A","인수합병","적대적인수","공개매수","TOB","경영권분쟁","주주행동주의", + "주주총회","정기주총","임시주총","이사회","대표이사교체","CEO교체","감사의견", + "한정의견","의견거절","부적정의견","횡령","배임","회계부정","분식회계","리콜", + "소송","피소","과징금","제재","영업정지","특허침해","임상성공","임상실패", + "FDA승인","식약처승인","NDA","IND","임상1상","임상2상","임상3상", +} + +# ── 거시경제 / 정책 ────────────────────────────────────────────────────────────── +MACRO_ECONOMICS = { + "기준금리","금리인상","금리인하","금리동결","통화정책","양적완화","QE","QT", + "테이퍼링","연준","FED","FOMC","한국은행","한은","ECB","BOJ","금통위", + "환율","달러원","원달러","엔화","위안화","유로","달러인덱스","DXY", + "인플레이션","디플레이션","스태그플레이션","CPI","PPI","PCE","GDP","GNP", + "경상수지","무역수지","외환보유고","국채","회사채","하이일드","신용등급", + "무디스","S&P","피치","국제유가","WTI","브렌트","천연가스","LNG","구리","금","은", + "반도체수급","공급망","서플라이체인","리쇼어링","니어쇼어링","관세","무역전쟁", + "경제성장률","실업률","소비자심리지수","PMI","ISM","주택착공","소매판매", +} + +# ── 산업 / 섹터 키워드 ─────────────────────────────────────────────────────────── +SECTOR_KEYWORDS = { + # 반도체 + "반도체","메모리","낸드","D램","HBM","HBM3","HBM4","파운드리","팹리스", + "TSMC","삼성파운드리","파운드리","EUV","GAA","3나노","2나노","첨단패키징", + "CoWoS","인터포저","칩렛","AI반도체","NPU","GPU","AI가속기", + # 2차전지/전기차 + "배터리","2차전지","리튬이온","전고체배터리","양극재","음극재","분리막","전해질", + "전기차","BEV","PHEV","HEV","충전인프라","급속충전","에너지밀도","배터리팩", + "배터리셀","모듈","BMS","리튬","코발트","니켈","망간","황산리튬", + # 바이오/헬스 + "바이오","제약","의약품","신약","오리지널","제네릭","바이오시밀러","항체","mRNA", + "세포치료","유전자치료","CAR-T","ADC","이중항체","희귀질환","항암제","치매", + "알츠하이머","비만치료제","GLP-1","당뇨","심혈관","호흡기", + # AI/IT/플랫폼 + "인공지능","AI","LLM","GPT","클로드","제미나이","거대언어모델","생성AI","AGI", + "클라우드","AWS","애저","GCP","SaaS","PaaS","IaaS","데이터센터","GPU서버", + "반도체설계","엣지AI","온디바이스","자율주행","ADAS","라이다","카메라모듈", + "로봇","협동로봇","물류로봇","휴머노이드", + # 방산/우주 + "방산","방위산업","무기수출","미사일","드론","무인기","위성","우주항공","발사체", + # 건설/부동산 + "부동산","아파트","분양","착공","준공","건설수주","PF","프로젝트파이낸싱", + "리츠","REITs","임대","역세권","재개발","재건축", +} + +# ── 가격 움직임 표현 (감성 분석용) ─────────────────────────────────────────────── +PRICE_MOVEMENT = { + "급등","급락","폭등","폭락","상한가","하한가","강세","약세","반등","반락", + "상승","하락","보합","소폭상승","소폭하락","대폭상승","대폭하락", + "신고가경신","52주신고가","역사적신고가","저점매수","고점매도", + "눌림","눌림목","지지","저항돌파","박스권","횡보","추세전환", + "모멘텀강화","모멘텀약화","수급개선","수급악화","외국인순매수","외국인순매도", + "기관순매수","기관순매도","공매도잔고","공매도비율", +} + +# ── 투자 전략 / 버핏 스타일 ────────────────────────────────────────────────────── +INVESTMENT_STRATEGY = { + "가치투자","성장투자","배당투자","퀀트투자","모멘텀투자","역발상투자", + "내재가치","안전마진","경제적해자","모트","경쟁우위","진입장벽","가격결정력", + "브랜드가치","플랫폼효과","네트워크효과","전환비용","규모의경제","비용우위", + "장기투자","복리효과","분산투자","집중투자","포트폴리오","자산배분","리밸런싱", + "저PER","저PBR","고ROE","배당수익률","배당성향","주주환원","자본배분", + "현금창출력","잉여현금흐름","재투자수익률","ROIC","WACC", +} + +# 동사/형용사 형태 (형태소 분석에서 VV/VA 태그로 뽑히는 것들) +FINANCE_VERBS: set[str] = { + "급등","급락","폭등","폭락","상승","하락","반등","돌파","이탈","회복", + "초과","달성","하회","상회","급성장","개선","악화","감소","증가", +} + +# 전체 통합 사전 (lookup용) +ALL_FINANCE_TERMS: set[str] = ( + FINANCIAL_STATEMENT + | MARKET_STRUCTURE + | TECHNICAL_INDICATORS + | CORPORATE_EVENTS + | MACRO_ECONOMICS + | SECTOR_KEYWORDS + | PRICE_MOVEMENT + | INVESTMENT_STRATEGY +) + +# 감성 판단에 중요한 우선순위 높은 단어들 +HIGH_PRIORITY_TERMS: set[str] = { + "어닝서프라이즈","어닝쇼크","급등","급락","폭등","폭락","상한가","하한가", + "수주","임상성공","임상실패","FDA승인","횡령","배임","분식회계","상장폐지", + "관리종목","거래정지","배당증가","자사주소각","M&A","합병","분할", + "골든크로스","데드크로스","신고가","신저가","52주신고가","52주신저가", + "외국인순매수","기관순매수","공매도잔고감소","실적발표","가이던스상향", + "가이던스하향","CEO교체","대표이사교체","감사의견거절", +} diff --git a/bareunaapi/main.py b/bareunaapi/main.py new file mode 100644 index 0000000..a798870 --- /dev/null +++ b/bareunaapi/main.py @@ -0,0 +1,250 @@ +""" +바른 API FastAPI 서버 v2 +- 서버 시작 시 KRX 전체 종목 동적 로딩 +- 24시간마다 자동 갱신 +- Bareun gRPC 연결 +""" +import asyncio, hashlib, os, re, time +from contextlib import asynccontextmanager +from typing import Optional +import orjson, redis.asyncio as aioredis, structlog +from bareunpy import Tagger +from fastapi import FastAPI, HTTPException, Response +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import BaseModel, Field +from stock_loader import load_all_stocks, auto_refresh_stocks +from finance_dict import ALL_FINANCE_TERMS, HIGH_PRIORITY_TERMS, FINANCE_VERBS as FD_VERBS + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +BAREUN_API_KEY = os.getenv("BAREUN_API_KEY", "") +BAREUN_SERVER_HOST = os.getenv("BAREUN_SERVER_HOST", "bareun") +BAREUN_SERVER_PORT = int(os.getenv("BAREUN_SERVER_PORT", "5656")) +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +REDIS_DB = int(os.getenv("REDIS_DB", "1")) +NEWS_DEDUP_TTL = int(os.getenv("NEWS_DEDUP_TTL", "86400")) + +STOPWORDS = { + "것","수","등","및","또","또한","이","그","저","위","아래","관련","통해", + "위해","대해","따라","때문","이후","이전","현재","최근","지난","올해","내년", + "이번","오늘","어제","내일","이날","같은","다른","많은","더","가장","매우", + "이미","아직","모든","각","전체","일부","국내","해외","글로벌","세계","한국", + "미국","중국","일본","유럽","가운데","한편","다만","특히","실제","여전히", + "앞으로","지속","계속","자체","관계자","측","쪽","곳","점","경우","상황", + "때","중","간", +} +FINANCE_KEYWORDS = ALL_FINANCE_TERMS +FINANCE_VERBS = { + "급등","급락","폭등","폭락","상승","하락","반등","하락세","상승세", + "돌파","이탈","회복","저항","지지","돌파구","매집","매도","매수", + "초과달성","하회","상회","달성","부진","급성장","감소","증가","개선","악화", +} | FD_VERBS +START_TIME = time.time() + +class AppState: + tagger: Optional[Tagger] = None + redis: Optional[aioredis.Redis] = None + stock_map: dict[str, str] = {} + stock_count: int = 0 + refresh_task: Optional[asyncio.Task] = None + +state = AppState() + +@asynccontextmanager +async def lifespan(app: FastAPI): + state.stock_map = await load_all_stocks() + state.stock_count = len(state.stock_map) + logger.info("stocks.ready", count=state.stock_count) + state.refresh_task = asyncio.create_task(auto_refresh_stocks(state, 24)) + try: + state.tagger = Tagger(BAREUN_API_KEY, BAREUN_SERVER_HOST, BAREUN_SERVER_PORT) + logger.info("tagger.ok") + except Exception as e: + logger.warning("tagger.failed", error=str(e)) + try: + state.redis = aioredis.Redis(host=REDIS_HOST, port=REDIS_PORT, + password=REDIS_PASSWORD, db=REDIS_DB, decode_responses=True, + socket_connect_timeout=5, retry_on_timeout=True) + await state.redis.ping() + logger.info("redis.ok") + except Exception as e: + logger.error("redis.failed", error=str(e)) + yield + if state.refresh_task: state.refresh_task.cancel() + if state.redis: await state.redis.aclose() + +app = FastAPI(title="바른 API v2", version="2.0.0", lifespan=lifespan) +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) +Instrumentator().instrument(app).expose(app, endpoint="/metrics") + +class AnalyzeRequest(BaseModel): + title: str; content: str = ""; url: str = ""; source: str = ""; published_at: str = "" + +class StockMention(BaseModel): + name: str; code: str; count: int + +class AnalyzeResponse(BaseModel): + hash: str; is_duplicate: bool; stocks: list[StockMention]; keywords: list[str] + filtered_text: str; token_count: int; processing_time_ms: float + +class BatchRequest(BaseModel): + items: list[AnalyzeRequest] + +def news_hash(title, url): + return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16] + +async def is_duplicate(h): + if not state.redis: return False + try: + r = await state.redis.set(f"news:dedup:{h}", "1", ex=NEWS_DEDUP_TTL, nx=True) + return r is None + except: return False + +def extract_morphemes(text): + if not state.tagger or not text.strip(): + return [w for w in text.split() if len(w) >= 2 and w not in STOPWORDS] + try: + result = [] + for t, p in state.tagger.pos(text): + if len(t) < 2 or t in STOPWORDS: + continue + if p in ("NNG", "NNP", "SL"): + result.append(t) + elif p in ("VV", "VA", "XR") and t in FINANCE_VERBS: + result.append(t) + return result + except: + return [w for w in text.split() if len(w) >= 2 and w not in STOPWORDS] + +def extract_stocks(text): + found = {} + for name, code in state.stock_map.items(): + if len(name) < 2: + continue # 1글자 종목명은 오탐 과다 → 제외 + if name.isascii(): + # 영문/숫자 약칭(KT·SK·DB 등)은 단어경계 강제 (SKT·KTX 오탐 차단) + pat = rf"(? 0: + found[name] = StockMention(name=name, code=code, count=c) + # 더 긴 종목명에 포함된 짧은 종목명 제거 ('한국' ⊂ '한국전력', 'KT' ⊂ 'KT&G') + names = list(found) + for n in names: + if any(n != m and n in m for m in names): + found.pop(n, None) + return sorted(found.values(), key=lambda x: x.count, reverse=True) + +def build_filtered(nouns, stocks): + sn = {s.name for s in stocks} + seen = set() + result = [] + # 1순위: 고중요도 금융 이벤트 (어닝서프라이즈, 상한가 등) + for n in nouns: + if n in HIGH_PRIORITY_TERMS and n not in seen: + result.append(n); seen.add(n) + # 2순위: 종목명 + for n in nouns: + if n in sn and n not in seen: + result.append(n); seen.add(n) + # 3순위: 전문 금융 용어 사전 + for n in nouns: + if n in FINANCE_KEYWORDS and n not in seen: + result.append(n); seen.add(n) + # 4순위: 나머지 의미있는 명사 + for n in nouns: + if n not in STOPWORDS and len(n) >= 2 and n not in seen: + result.append(n); seen.add(n) + return " ".join(result[:120]) + +def scan_finance_terms(text: str) -> list[str]: + """형태소 분석 없이 원문에서 금융 전문 용어 직접 탐색 (복합어 대응)""" + found = [] + for term in ALL_FINANCE_TERMS: + if term in text: + found.append(term) + return found + +def _analyze(req): + text = f"{req.title} {req.content}".strip() + h = news_hash(req.title, req.url) + nouns = extract_morphemes(text) + # 원문 직접 스캔으로 형태소 분석이 놓친 복합어 추가 + direct_terms = scan_finance_terms(text) + nouns = list(dict.fromkeys(nouns + direct_terms)) + stocks = extract_stocks(text) + kw = list(dict.fromkeys(n for n in nouns if len(n) >= 2))[:50] + ft = build_filtered(nouns, stocks) + return h, stocks, kw, ft + +@app.get("/health") +async def health(): + tok = state.tagger is not None + rok = False + if state.redis: + try: await state.redis.ping(); rok = True + except: pass + return JSONResponse(content={"status": "ok" if tok else "degraded", + "tagger": "ok" if tok else "unavailable", "redis": "ok" if rok else "error", + "stocks_loaded": state.stock_count, "uptime": round(time.time()-START_TIME,1)}) + +@app.post("/analyze") +async def analyze(req: AnalyzeRequest): + t = time.perf_counter() + h, stocks, kw, ft = _analyze(req) + dup = await is_duplicate(h) + ms = round((time.perf_counter()-t)*1000, 2) + return Response(content=orjson.dumps(AnalyzeResponse( + hash=h, is_duplicate=dup, stocks=stocks, keywords=kw, + filtered_text=ft, token_count=len(ft.split()), processing_time_ms=ms + ).model_dump()), media_type="application/json") + +@app.post("/analyze/batch") +async def analyze_batch(req: BatchRequest): + if len(req.items) > 50: raise HTTPException(400, "최대 50개") + t = time.perf_counter() + results = [] + for item in req.items: + try: + h, stocks, kw, ft = _analyze(item) + dup = await is_duplicate(h) + results.append({"title":item.title,"hash":h,"is_duplicate":dup, + "stocks":[s.model_dump() for s in stocks],"keywords":kw, + "filtered_text":ft,"token_count":len(ft.split())}) + except Exception as e: + results.append({"title":item.title,"error":str(e),"is_duplicate":False}) + ms = round((time.perf_counter()-t)*1000, 2) + dups = sum(1 for r in results if r.get("is_duplicate")) + return Response(content=orjson.dumps({"total":len(results),"duplicates":dups, + "processed":len(results)-dups,"elapsed_ms":ms,"results":results}), + media_type="application/json") + +@app.get("/stocks") +async def stocks_list(): + return JSONResponse(content={"count":len(state.stock_map), + "stocks":[{"name":k,"code":v} for k,v in list(state.stock_map.items())[:500]]}) + +@app.post("/stocks/refresh") +async def refresh(): + m = await load_all_stocks() + if len(m) > 50: + state.stock_map = m; state.stock_count = len(m) + return JSONResponse(content={"status":"ok","count":len(m)}) + raise HTTPException(500, "종목 로딩 실패") + +@app.delete("/dedup/flush") +async def flush(): + if not state.redis: raise HTTPException(503) + keys = await state.redis.keys("news:dedup:*") + if keys: await state.redis.delete(*keys) + return {"deleted": len(keys)} diff --git a/bareunaapi/requirements.txt b/bareunaapi/requirements.txt new file mode 100644 index 0000000..7a571bc --- /dev/null +++ b/bareunaapi/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +uvloop==0.19.0 +httptools==0.6.1 +httpx==0.27.0 +redis==5.0.4 +pydantic==2.7.1 +pydantic-settings==2.3.1 +bareunpy==1.7.3 +python-multipart==0.0.9 +orjson==3.10.3 +structlog==24.2.0 +prometheus-fastapi-instrumentator==7.0.0 +tenacity==8.3.0 diff --git a/bareunaapi/stock_loader.py b/bareunaapi/stock_loader.py new file mode 100644 index 0000000..cbf543e --- /dev/null +++ b/bareunaapi/stock_loader.py @@ -0,0 +1,162 @@ +""" +KRX 전체 종목 동적 로딩 +- 서버 시작 시 1회 로딩 +- 매일 자정 자동 갱신 +- 실패 시 하드코딩 폴백 +""" + +import asyncio +import json +import io +import time +from datetime import datetime + +import httpx +import structlog + +logger = structlog.get_logger() + +# 폴백 종목 (네트워크 실패 시 사용) +FALLBACK_STOCKS = { + "코스피": "KOSPI", "코스닥": "KOSDAQ", "코스피200": "KOSPI200", + "삼성전자": "005930", "SK하이닉스": "000660", "LG에너지솔루션": "373220", + "현대차": "005380", "기아": "000270", "셀트리온": "068270", + "카카오": "035720", "네이버": "035420", "NAVER": "035420", + "삼성바이오로직스": "207940", "KB금융": "105560", "POSCO홀딩스": "005490", + "신한지주": "055550", "LG화학": "051910", "삼성SDI": "006400", + "현대모비스": "012330", "하나금융지주": "086790", "SK텔레콤": "017670", + "KT": "030200", "LG전자": "066570", "한화에어로스페이스": "012450", + "삼성물산": "028260", "HD현대중공업": "329180", "한국전력": "015760", + "HMM": "011200", "대한항공": "003490", "카카오뱅크": "323410", + "크래프톤": "259960", "하이브": "352820", "에코프로": "086520", + "알테오젠": "196170", "한미반도체": "042700", +} + +# 약칭/별칭 +ALIASES = { + "삼전": "005930", "하닉": "000660", "현차": "005380", + "카뱅": "323410", "삼바": "207940", "삼성바이오": "207940", + "한에솔": "012450", "한화에어": "012450", "LG엔솔": "373220", + "SK하닉": "000660", "포홀": "005490", "에프엔에프": "383220", + "현대자동차": "005380", "네이버": "035420", +} + + +async def fetch_krx_stocks() -> dict[str, str]: + """KRX에서 전체 상장 종목 가져오기""" + stock_map = { + "코스피": "KOSPI", "코스닥": "KOSDAQ", + "코스피200": "KOSPI200", "KRX": "KRX", + } + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "http://data.krx.co.kr/contents/MDC/MDI/mdiLoader/index.cmd", + } + + async with httpx.AsyncClient(timeout=30, headers=headers) as client: + for mkt_id, mkt_name in [("STK", "KOSPI"), ("KSQ", "KOSDAQ")]: + try: + payload = { + "bld": "dbms/MDC/STAT/standard/MDCSTAT01901", + "locale": "ko_KR", + "mktId": mkt_id, + "share": "1", + "csvxls_is498": "false", + } + resp = await client.post( + "http://data.krx.co.kr/comm/bldAttend/getJsonData.cmd", + data=payload, + ) + if resp.status_code == 200: + data = resp.json() + items = data.get("OutBlock_1", []) + for item in items: + name = item.get("ISU_ABBRV", "").strip() + code = item.get("ISU_SRT_CD", "").strip() + if name and code and len(code) == 6: + stock_map[name] = code + logger.info("krx.loaded", market=mkt_name, count=len(items)) + else: + logger.warning("krx.http_error", market=mkt_name, status=resp.status_code) + except Exception as e: + logger.warning("krx.fetch_failed", market=mkt_name, error=str(e)) + + # KRX 실패 시 네이버 금융 폴백 + if len(stock_map) < 100: + logger.info("krx.fallback_naver", reason="KRX returned too few stocks") + stock_map.update(await fetch_naver_stocks()) + + return stock_map + + +async def fetch_naver_stocks() -> dict[str, str]: + """네이버 금융에서 종목 가져오기 (KRX 폴백)""" + stock_map = {} + headers = {"User-Agent": "Mozilla/5.0"} + import re + + async with httpx.AsyncClient(timeout=15, headers=headers) as client: + for sosok in [0, 1]: # 0=KOSPI, 1=KOSDAQ + for page in range(1, 45): + try: + url = f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}" + resp = await client.get(url) + text = resp.content.decode("euc-kr", errors="ignore") + rows = re.findall( + r"main\.naver\?code=(\d{6})[^>]*>([^<]+)", text + ) + if not rows: + break + for code, name in rows: + name = name.strip() + if name and code: + stock_map[name] = code + except Exception: + break + + logger.info("naver.loaded", count=len(stock_map)) + return stock_map + + +async def load_all_stocks() -> dict[str, str]: + """전체 종목 로딩 (KRX → 네이버 → 폴백)""" + try: + stock_map = await fetch_krx_stocks() + if len(stock_map) > 100: + stock_map.update(ALIASES) + logger.info("stocks.loaded", total=len(stock_map), source="KRX") + return stock_map + except Exception as e: + logger.warning("stocks.krx_failed", error=str(e)) + + try: + stock_map = await fetch_naver_stocks() + if len(stock_map) > 100: + stock_map.update({ + "코스피": "KOSPI", "코스닥": "KOSDAQ", + "코스피200": "KOSPI200", "KRX": "KRX", + }) + stock_map.update(ALIASES) + logger.info("stocks.loaded", total=len(stock_map), source="Naver") + return stock_map + except Exception as e: + logger.warning("stocks.naver_failed", error=str(e)) + + # 최종 폴백 + fallback = {**FALLBACK_STOCKS, **ALIASES} + logger.warning("stocks.using_fallback", total=len(fallback)) + return fallback + + +async def auto_refresh_stocks(state_ref, interval_hours: int = 24): + """백그라운드에서 종목 목록 자동 갱신""" + while True: + await asyncio.sleep(interval_hours * 3600) + try: + new_map = await load_all_stocks() + if len(new_map) > 50: + state_ref.stock_map = new_map + logger.info("stocks.refreshed", total=len(new_map)) + except Exception as e: + logger.warning("stocks.refresh_failed", error=str(e)) diff --git a/dart-collector/Dockerfile b/dart-collector/Dockerfile new file mode 100644 index 0000000..6a93a88 --- /dev/null +++ b/dart-collector/Dockerfile @@ -0,0 +1,9 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl unzip && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p /app/data /app/logs +EXPOSE 8888 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8888", "--workers", "1", "--log-level", "info"] diff --git a/dart-collector/docker-compose-addition.yml b/dart-collector/docker-compose-addition.yml new file mode 100644 index 0000000..2cb0b6b --- /dev/null +++ b/dart-collector/docker-compose-addition.yml @@ -0,0 +1,51 @@ +# docker-compose.yml services: 블록 안에 추가 + + # ────────────────────────────────────────── + # DART 공시 수집기 172.30.0.17 + # ────────────────────────────────────────── + dart-collector: + build: + context: ./dart-collector + dockerfile: Dockerfile + container_name: trading-dart-collector + restart: unless-stopped + ports: + - "8888:8888" + environment: + DART_API_KEY: "${DART_API_KEY}" + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + BAREUN_API_URL: "http://bareunaapi:5757" + OLLAMA_URL: "http://ollama:11434" + VLLM_URL: "http://vllm:8000" + QDRANT_URL: "http://qdrant:6333" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.17 + depends_on: + redis: + condition: service_healthy + bareunaapi: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8888/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "10" diff --git a/dart-collector/main.py b/dart-collector/main.py new file mode 100644 index 0000000..0e1239e --- /dev/null +++ b/dart-collector/main.py @@ -0,0 +1,1297 @@ +""" +DART 공시 수집기 +- 전체 공시 실시간 수집 (10분마다) +- 재무제표 수집 (매일 1회) +- 기업 기본정보 (서버 시작 시 + 매일 갱신) +- 주요사항보고 수집 +- 수집 즉시 AI 분석 파이프라인 연동 +""" + +import asyncio +import io +import json +import os +import re +import time +import zipfile +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta +from typing import Optional + +import asyncpg +import httpx +import redis.asyncio as aioredis +import structlog +import xmltodict +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI, HTTPException, Query +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +# ── 환경변수 ────────────────────────────────────────────── +DART_API_KEY = os.getenv("DART_API_KEY", "") +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +PG_HOST = os.getenv("POSTGRES_HOST", "postgres") +PG_PORT = int(os.getenv("POSTGRES_PORT", "5432")) +PG_DB = os.getenv("POSTGRES_DB", "trading_ai") +PG_USER = os.getenv("POSTGRES_USER", "kyu") +PG_PASS = os.getenv("POSTGRES_PASSWORD", "7895123") +BAREUN_API_URL = os.getenv("BAREUN_API_URL", "http://bareunaapi:5757") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") +QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333") + +DART_BASE = "https://opendart.fss.or.kr/api" + +# ── 전역 상태 ───────────────────────────────────────────── +pg_pool: Optional[asyncpg.Pool] = None +redis_client: Optional[aioredis.Redis] = None +corp_codes: dict[str, dict] = {} # {stock_code: {corp_code, corp_name, ...}} +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + +class Stats: + disclosures: int = 0 + financials: int = 0 + corps_loaded: int = 0 + last_run: str = "" + errors: int = 0 + +stats = Stats() + + +# ══════════════════════════════════════════════════════════ +# 1. 기업 기본정보 (전체 상장 종목 코드) +# ══════════════════════════════════════════════════════════ + +async def load_corp_codes(): + """DART 고유번호 전체 다운로드 → 종목코드 매핑""" + global corp_codes + logger.info("corp_codes.loading") + + async with httpx.AsyncClient(timeout=60) as client: + resp = await client.get(f"{DART_BASE}/corpCode.xml", params={"crtfc_key": DART_API_KEY}) + + if resp.status_code != 200: + logger.error("corp_codes.download_failed", status=resp.status_code) + return + + # ZIP 파일 안에 XML + zf = zipfile.ZipFile(io.BytesIO(resp.content)) + xml_data = zf.read(zf.namelist()[0]) + root = ET.fromstring(xml_data) + + new_map = {} + for item in root.findall(".//list"): + corp_code = item.findtext("corp_code", "") + corp_name = item.findtext("corp_name", "") + stock_code = item.findtext("stock_code", "").strip() + modify_date = item.findtext("modify_date", "") + + if stock_code and len(stock_code) == 6: + new_map[stock_code] = { + "corp_code": corp_code, + "corp_name": corp_name, + "stock_code": stock_code, + "modify_date": modify_date, + } + + corp_codes = new_map + stats.corps_loaded = len(corp_codes) + logger.info("corp_codes.loaded", count=len(corp_codes)) + + # DB에 저장 + if pg_pool: + await save_corp_codes_to_db() + + +async def fetch_krx_active_codes() -> set: + """KRX에서 현재 상장 중인 종목코드만 가져오기 (KRX 실패시 네이버 폴백)""" + import re as _re + active = set() + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "http://data.krx.co.kr/contents/MDC/MDI/mdiLoader/index.cmd", + } + async with httpx.AsyncClient(timeout=30, headers=headers) as client: + for mkt_id in ("STK", "KSQ"): + try: + resp = await client.post( + "http://data.krx.co.kr/comm/bldAttend/getJsonData.cmd", + data={ + "bld": "dbms/MDC/STAT/standard/MDCSTAT01901", + "locale": "ko_KR", + "mktId": mkt_id, + "share": "1", + "csvxls_is498": "false", + }, + ) + if resp.status_code == 200: + data = resp.json() + for item in data.get("OutBlock_1", []): + code = item.get("ISU_SRT_CD", "").strip() + if len(code) == 6: + active.add(code) + except Exception as e: + logger.warning("krx.active_fetch_failed", market=mkt_id, error=str(e)) + + # KRX 실패시 네이버 금융 폴백 + if len(active) < 100: + logger.warning("krx.active_fallback_naver", krx_count=len(active)) + try: + async with httpx.AsyncClient(timeout=15, headers={"User-Agent": "Mozilla/5.0"}, + follow_redirects=True) as nav: + for sosok in [0, 1]: + for page in range(1, 50): + try: + r = await nav.get( + f"https://finance.naver.com/sise/sise_market_sum.naver" + f"?sosok={sosok}&page={page}") + text = r.content.decode("euc-kr", errors="ignore") + codes = _re.findall(r"main\.naver\?code=(\d{6})", text) + if not codes: + break + active.update(codes) + except Exception: + break + logger.info("naver.active_loaded", count=len(active)) + except Exception as e: + logger.warning("naver.active_fetch_failed", error=str(e)) + + logger.info("krx.active_loaded", count=len(active)) + return active + + +async def save_corp_codes_to_db(): + """기업 기본정보 DB 배치 저장 + KRX 현재 상장 여부 표시""" + active_codes = await fetch_krx_active_codes() + + async with pg_pool.acquire() as conn: + await conn.execute("DELETE FROM dart_corps") + records = [ + (v["stock_code"], v["corp_code"], v["corp_name"], v["modify_date"], + v["stock_code"] in active_codes) + for v in corp_codes.values() + ] + await conn.executemany( + "INSERT INTO dart_corps (stock_code, corp_code, corp_name, modify_date, is_active)" + " VALUES ($1,$2,$3,$4,$5)", + records + ) + active_saved = sum(1 for r in records if r[4]) + logger.info("corp_codes.saved_to_db", total=len(corp_codes), active=active_saved) + +async def fetch_krx_sectors() -> dict: + """ + KRX 산업분류 (전종목 KRX 산업코드/업종명) → {stock_code: sector_name} + bld: dbms/MDC/STAT/standard/MDCSTAT03901 (KRX 산업분류) + """ + out: dict = {} + headers = { + "User-Agent": "Mozilla/5.0", + "Referer": "http://data.krx.co.kr/contents/MDC/MDI/mdiLoader/index.cmd", + } + async with httpx.AsyncClient(timeout=30, headers=headers) as client: + for mkt_id in ("STK", "KSQ"): + try: + resp = await client.post( + "http://data.krx.co.kr/comm/bldAttend/getJsonData.cmd", + data={ + "bld": "dbms/MDC/STAT/standard/MDCSTAT03901", + "locale": "ko_KR", + "mktId": mkt_id, + "trdDd": datetime.now().strftime("%Y%m%d"), + "money": "1", + "csvxls_is498": "false", + }, + ) + if resp.status_code == 200: + data = resp.json() + for item in data.get("block1", []): + code = (item.get("ISU_SRT_CD") or "").strip() + sec = (item.get("IDX_IND_NM") or "").strip() + if len(code) == 6 and sec: + out[code] = sec + except Exception as e: + logger.warning("krx.sectors_fetch_failed", market=mkt_id, error=str(e)) + logger.info("krx.sectors_loaded", count=len(out)) + return out + + +async def collect_sectors(): + """KRX 섹터 정보를 dart_corps.sector 컬럼에 저장""" + sectors = await fetch_krx_sectors() + if not sectors: + return 0 + saved = 0 + async with pg_pool.acquire() as conn: + for code, sec in sectors.items(): + res = await conn.execute( + "UPDATE dart_corps SET sector=$1 WHERE stock_code=$2", + sec, code) + if "1" in res: + saved += 1 + logger.info("sectors.saved", saved=saved) + return saved + + +def get_corp_code(stock_code: str) -> str: + """종목코드 → DART 고유번호""" + info = corp_codes.get(stock_code) + return info["corp_code"] if info else "" + + +def get_corp_name(stock_code: str) -> str: + info = corp_codes.get(stock_code) + return info["corp_name"] if info else "" + + +# ══════════════════════════════════════════════════════════ +# 2. 공시 전체 수집 +# ══════════════════════════════════════════════════════════ + +async def fetch_disclosures( + bgn_de: str = "", end_de: str = "", + corp_code: str = "", pblntf_ty: str = "", + page_no: int = 1, page_count: int = 100, +) -> list[dict]: + """DART 공시 목록 조회""" + if not bgn_de: + bgn_de = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") + if not end_de: + end_de = datetime.now().strftime("%Y%m%d") + + params = { + "crtfc_key": DART_API_KEY, + "bgn_de": bgn_de, + "end_de": end_de, + "page_no": page_no, + "page_count": page_count, + } + if corp_code: + params["corp_code"] = corp_code + if pblntf_ty: + params["pblntf_ty"] = pblntf_ty # A:정기공시, B:주요사항, C:발행공시, D:지분공시, E:기타공시, F:외부감사, G:펀드, H:자산유동화, I:거래소, J:공정위, K:기타 + + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{DART_BASE}/list.json", params=params) + data = resp.json() + + if data.get("status") != "000": + return [] + + return data.get("list", []) + + +async def collect_recent_disclosures(): + """최근 공시 수집 + AI 분석""" + logger.info("disclosures.collecting") + today = datetime.now().strftime("%Y%m%d") + yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y%m%d") + + all_disclosures = [] + + # 공시 유형별 수집 + types = { + "A": "정기공시", # 사업보고서, 분기보고서 + "B": "주요사항", # 유상증자, 합병, 분할 등 + "C": "발행공시", # 채권, 주식 발행 + "D": "지분공시", # 대량보유, 임원 지분변동 + "E": "기타공시", + "I": "거래소공시", # 조회공시, 공시번복 등 + } + + for type_code, type_name in types.items(): + try: + items = await fetch_disclosures( + bgn_de=yesterday, end_de=today, pblntf_ty=type_code + ) + for item in items: + item["pblntf_ty_name"] = type_name + all_disclosures.extend(items) + await asyncio.sleep(0.5) # Rate limit + except Exception as e: + logger.warning("disclosures.type_error", type=type_name, error=str(e)) + + logger.info("disclosures.fetched", total=len(all_disclosures)) + + # 중복 제거 + DB 저장 + AI 분석 + new_count = 0 + for disc in all_disclosures: + rcept_no = disc.get("rcept_no", "") + + # Redis 중복 체크 + if redis_client: + exists = await redis_client.exists(f"dart:disc:{rcept_no}") + if exists: + continue + await redis_client.set(f"dart:disc:{rcept_no}", "1", ex=86400 * 7) + + # DB 저장 + if pg_pool: + await save_disclosure(disc) + + # AI 분석 파이프라인 + await analyze_disclosure(disc) + new_count += 1 + await asyncio.sleep(0.3) + + stats.disclosures += new_count + stats.last_run = datetime.now().isoformat() + logger.info("disclosures.done", new=new_count, total=len(all_disclosures)) + + +async def save_disclosure(disc: dict): + """공시 DB 저장""" + try: + async with pg_pool.acquire() as conn: + await conn.execute(""" + INSERT INTO dart_disclosures + (rcept_no, rcept_dt, corp_code, corp_name, stock_code, + corp_cls, report_nm, flr_nm, pblntf_ty, pblntf_detail_ty) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (rcept_no) DO NOTHING + """, + disc.get("rcept_no", ""), + disc.get("rcept_dt", ""), + disc.get("corp_code", ""), + disc.get("corp_name", ""), + disc.get("stock_code", ""), + disc.get("corp_cls", ""), + disc.get("report_nm", ""), + disc.get("flr_nm", ""), + disc.get("pblntf_ty", ""), + disc.get("pblntf_detail_ty", ""), + ) + except Exception as e: + logger.warning("disclosure.save_error", error=str(e)) + + +async def analyze_disclosure(disc: dict): + """공시를 AI 분석 파이프라인으로 전달""" + corp_name = disc.get("corp_name", "") + stock_code = disc.get("stock_code", "") + report_nm = disc.get("report_nm", "") + pblntf_ty = disc.get("pblntf_ty_name", "") + + title = f"[{pblntf_ty}] {corp_name}({stock_code}) - {report_nm}" + content = f"기업: {corp_name}, 종목코드: {stock_code}, 공시유형: {pblntf_ty}, 공시명: {report_nm}" + + try: + async with httpx.AsyncClient(timeout=120) as client: + # vLLM AI 분석 + prompt = ( + f"DART 공시 분석:\n" + f"기업: {corp_name} ({stock_code})\n" + f"공시유형: {pblntf_ty}\n" + f"공시명: {report_nm}\n\n" + "위 공시가 해당 종목에 미치는 영향을 분석하여 JSON으로 응답:\n" + '{"sentiment":"호재 또는 악재 또는 중립",' + '"intensity":1에서5,' + f'"primary_stock":"{stock_code}",' + '"affected_stocks":[],' + '"reason":"판단근거 2문장",' + '"investment_action":"매수관심 또는 매도관심 또는 관망"}' + ) + + vllm_resp = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={ + "model": "exaone3.5:7.8b", + "messages": [ + {"role": "system", "content": "당신은 한국 주식 DART 공시 분석 전문가입니다. JSON으로만 응답하세요."}, + {"role": "user", "content": prompt}, + ], + "max_tokens": 300, "temperature": 0.1, + }) + + analysis = {} + try: + c = vllm_resp.json()["choices"][0]["message"]["content"] + analysis = json.loads(re.sub(r"```json\n?|```", "", c).strip()) + except Exception: + analysis = {"sentiment": "중립", "intensity": 0, "reason": "파싱실패", + "primary_stock": stock_code, "affected_stocks": [], + "investment_action": "관망"} + + # news_analysis 테이블에 저장 (기존 파이프라인과 통합) + async with pg_pool.acquire() as conn: + h = disc.get("rcept_no", "")[:16] + escape = lambda s: (s or "").replace("'", "''") + await conn.execute(f""" + INSERT INTO news_analysis + (title, url, source, published_at, hash, sentiment, intensity, + primary_stock, affected_stocks, reason, investment_action, + keywords, stock_names, stock_codes, similar_count, analyzed_at) + VALUES ( + '{escape(title[:500])}', + 'https://dart.fss.or.kr/dsaf001/main.do?rcpNo={disc.get("rcept_no","")}', + 'DART공시', + '{disc.get("rcept_dt", datetime.now().strftime("%Y%m%d"))}', + '{h}', + '{analysis.get("sentiment","중립")}', + {analysis.get("intensity", 0)}, + '{escape(analysis.get("primary_stock",""))}', + '{json.dumps(analysis.get("affected_stocks",[]))}', + '{escape(analysis.get("reason",""))}', + '{analysis.get("investment_action","관망")}', + '{json.dumps([pblntf_ty, report_nm[:50]])}', + '{json.dumps([corp_name])}', + '{json.dumps([stock_code] if stock_code else [])}', + 0, + '{datetime.now().isoformat()}' + ) ON CONFLICT (hash) DO NOTHING + """) + except Exception as e: + logger.warning("disclosure.analyze_error", corp=corp_name, error=str(e)) + + +# ══════════════════════════════════════════════════════════ +# 3. 재무제표 수집 +# ══════════════════════════════════════════════════════════ + +async def fetch_financial( + corp_code: str, bsns_year: str, reprt_code: str = "11011" +) -> list[dict]: + """ + 재무제표 조회 + reprt_code: 11013=1분기, 11012=반기, 11014=3분기, 11011=사업보고서 + fs_div: CFS(연결) 우선, 없으면 OFS(별도) 폴백 + """ + params = { + "crtfc_key": DART_API_KEY, + "corp_code": corp_code, + "bsns_year": bsns_year, + "reprt_code": reprt_code, + "fs_div": "CFS", + } + async with httpx.AsyncClient(timeout=30) as client: + resp = await client.get(f"{DART_BASE}/fnlttSinglAcntAll.json", params=params) + data = resp.json() + if data.get("status") == "000": + return data.get("list", []) + # CFS가 없는 경우(013) 별도재무제표로 폴백 + if data.get("status") == "013": + params["fs_div"] = "OFS" + resp = await client.get(f"{DART_BASE}/fnlttSinglAcntAll.json", params=params) + data = resp.json() + if data.get("status") == "000": + return data.get("list", []) + # status 013 = 조회된 데이터 없음. 당해/직전 분기 보고서 미공시는 정상이므로 debug로 강등. + status = data.get("status") + if status == "013": + logger.debug("financial.no_data", + corp=corp_code, year=bsns_year, reprt=reprt_code, + msg=data.get("message")) + else: + logger.warning("financial.api_failed", + corp=corp_code, year=bsns_year, reprt=reprt_code, + status=status, msg=data.get("message")) + return [] + + +def calc_financial_ratios(key_items: dict, prev_revenue: int = 0) -> dict: + """버핏 스타일 재무 비율 계산""" + revenue = key_items.get("매출액", 0) + op_profit = key_items.get("영업이익", 0) + net_income = key_items.get("당기순이익", 0) + total_assets = key_items.get("자산총계", 0) + total_liabs = key_items.get("부채총계", 0) + total_equity = key_items.get("자본총계", 0) + op_cashflow = key_items.get("영업활동현금흐름", 0) + + roe = round(net_income / total_equity * 100, 2) if total_equity > 0 else 0.0 + operating_margin = round(op_profit / revenue * 100, 2) if revenue > 0 else 0.0 + net_margin = round(net_income / revenue * 100, 2) if revenue > 0 else 0.0 + debt_ratio = round(total_liabs / total_assets * 100, 2) if total_assets > 0 else 0.0 + fcf_ratio = round(op_cashflow / revenue * 100, 2) if revenue > 0 else 0.0 + revenue_growth = round((revenue - prev_revenue) / abs(prev_revenue) * 100, 2) \ + if prev_revenue and prev_revenue != 0 else 0.0 + return { + "roe": roe, + "operating_margin": operating_margin, + "net_margin": net_margin, + "debt_ratio": debt_ratio, + "fcf_ratio": fcf_ratio, + "revenue_growth": revenue_growth, + } + + +async def collect_financials_for_top_stocks(count: int = 300, years: int = 2, annual_only: bool = False): + """상장 종목 재무제표 수집 (is_active 기준) + years: 최근 N년치 수집 (기본 2년) + annual_only: True면 사업보고서(11011)만 수집 → 다년 백필 시 호출 부담 ↓ + """ + logger.info("financials.collecting", count=count, years=years, annual_only=annual_only) + + cur_year = datetime.now().year + year_list = [str(cur_year - i) for i in reversed(range(years))] # ASC: [...,prev,cur] + if annual_only: + report_codes = [("11011", "사업보고서")] + else: + report_codes = [ + ("11011", "사업보고서"), + ("11012", "반기보고서"), + ("11013", "1분기보고서"), + ("11014", "3분기보고서"), + ] + + # is_active 종목만 대상 + if pg_pool: + async with pg_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code LIMIT $1", count) + active_codes = {r["stock_code"] for r in rows} + else: + active_codes = set(list(corp_codes.keys())[:count]) + + stock_list = [(sc, info) for sc, info in corp_codes.items() if sc in active_codes] + + collected = 0 + for stock_code, info in stock_list: + corp_code = info["corp_code"] + corp_name = info["corp_name"] + + # 매출성장률용: 직전 연도 사업보고서 매출 (ASC 순회로 누적) + prev_revenue_annual = 0 + for reprt_code, reprt_name in report_codes: + for yr in year_list: + try: + items = await fetch_financial(corp_code, yr, reprt_code) + if not items: + continue + + # 자본변동표(SCE)는 '자본총계'/'당기순이익'이 시점별 8번 중복돼 + # 마지막 값이 음수가 되면 ROE 계산이 깨짐 → 본표만 사용 + valid_sj = ("재무상태표", "포괄손익계산서", "손익계산서", "현금흐름표") + aliases = { + "매출액": ("매출액", "수익(매출액)", "영업수익"), + "영업이익": ("영업이익", "영업이익(손실)"), + "당기순이익": ("당기순이익", "당기순이익(손실)", + "분기순이익", "분기순이익(손실)", + "반기순이익", "반기순이익(손실)"), + "자산총계": ("자산총계",), + "부채총계": ("부채총계",), + "자본총계": ("자본총계",), + "영업활동현금흐름": ("영업활동현금흐름", "영업활동으로 인한 현금흐름"), + } + key_items: dict = {} + for item in items: + if item.get("sj_nm", "") not in valid_sj: + continue + acnt_nm = item.get("account_nm", "") + amount = item.get("thstrm_amount", "") + for canonical, names in aliases.items(): + if acnt_nm in names and canonical not in key_items: + try: + key_items[canonical] = int(amount.replace(",", "")) + except (ValueError, AttributeError): + pass + break + + if key_items and pg_pool: + # 매출성장률: 11011 + 직전 연도 매출 있을 때만 의미 있음 + prev_rev = prev_revenue_annual if reprt_code == "11011" else 0 + ratios = calc_financial_ratios(key_items, prev_rev) + await save_financial( + stock_code, corp_code, corp_name, + yr, reprt_code, reprt_name, key_items, ratios + ) + collected += 1 + if reprt_code == "11011": + prev_revenue_annual = key_items.get("매출액", 0) + + await asyncio.sleep(0.2) + except Exception as e: + logger.warning("financial.error", code=stock_code, error=str(e)) + + stats.financials += collected + logger.info("financials.done", collected=collected) + + +async def save_financial( + stock_code, corp_code, corp_name, + bsns_year, reprt_code, reprt_name, key_items, ratios: dict = None +): + """재무제표 DB 저장 (비율 포함)""" + if ratios is None: + ratios = calc_financial_ratios(key_items) + try: + async with pg_pool.acquire() as conn: + await conn.execute(""" + INSERT INTO dart_financials + (stock_code, corp_code, corp_name, bsns_year, reprt_code, reprt_name, + revenue, operating_profit, net_income, total_assets, + total_liabilities, total_equity, operating_cashflow, + roe, operating_margin, net_margin, debt_ratio, fcf_ratio, revenue_growth, + collected_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20) + ON CONFLICT (stock_code, bsns_year, reprt_code) DO UPDATE SET + revenue=$7, operating_profit=$8, net_income=$9, + total_assets=$10, total_liabilities=$11, total_equity=$12, + operating_cashflow=$13, roe=$14, operating_margin=$15, + net_margin=$16, debt_ratio=$17, fcf_ratio=$18, revenue_growth=$19, + collected_at=$20 + """, + stock_code, corp_code, corp_name, bsns_year, reprt_code, reprt_name, + key_items.get("매출액", 0), + key_items.get("영업이익", 0), + key_items.get("당기순이익", 0), + key_items.get("자산총계", 0), + key_items.get("부채총계", 0), + key_items.get("자본총계", 0), + key_items.get("영업활동현금흐름", 0), + ratios.get("roe", 0.0), + ratios.get("operating_margin", 0.0), + ratios.get("net_margin", 0.0), + ratios.get("debt_ratio", 0.0), + ratios.get("fcf_ratio", 0.0), + ratios.get("revenue_growth", 0.0), + datetime.now(), + ) + except Exception as e: + logger.warning("financial.save_error", code=stock_code, error=str(e)) + + +# ══════════════════════════════════════════════════════════ +# 4. 주요사항보고 +# ══════════════════════════════════════════════════════════ + +async def collect_major_reports(): + """주요사항보고 수집 (유상증자, 합병, 자사주 등)""" + logger.info("major_reports.collecting") + + today = datetime.now().strftime("%Y%m%d") + week_ago = (datetime.now() - timedelta(days=7)).strftime("%Y%m%d") + + # B: 주요사항보고서 + items = await fetch_disclosures(bgn_de=week_ago, end_de=today, pblntf_ty="B") + + major_keywords = [ + "유상증자", "무상증자", "합병", "분할", "자사주", "전환사채", + "신주인수권", "교환사채", "주식매수선택권", "배당", "감자", + "해산", "파산", "회생", "상장폐지", "거래정지", + ] + + important = [] + for item in items: + report_nm = item.get("report_nm", "") + if any(kw in report_nm for kw in major_keywords): + item["is_major"] = True + important.append(item) + + for item in important: + rcept_no = item.get("rcept_no", "") + if redis_client: + exists = await redis_client.exists(f"dart:major:{rcept_no}") + if exists: + continue + await redis_client.set(f"dart:major:{rcept_no}", "1", ex=86400 * 30) + + item["pblntf_ty_name"] = "주요사항" + if pg_pool: + await save_disclosure(item) + await analyze_disclosure(item) + await asyncio.sleep(0.3) + + logger.info("major_reports.done", total=len(items), important=len(important)) + + +# ══════════════════════════════════════════════════════════ +# 5. 배당 데이터 수집 +# ══════════════════════════════════════════════════════════ + +async def collect_dividends(count: int = 200): + """상위 종목 배당 데이터 수집 (DART 배당에 관한 사항)""" + logger.info("dividends.collecting", count=count) + saved = 0 + async with pg_pool.acquire() as conn: + corps = await conn.fetch(""" + SELECT d.stock_code, d.corp_code, d.corp_name, + f.revenue + FROM dart_corps d + JOIN dart_financials f ON f.stock_code = d.stock_code + WHERE d.is_active = true AND d.corp_code != '' + ORDER BY f.bsns_year DESC, f.revenue DESC NULLS LAST + LIMIT $1 + """, count) + + bsns_year = str(datetime.now().year - 1) + async with httpx.AsyncClient(timeout=30) as client: + for corp in corps: + try: + resp = await client.get(f"{DART_BASE}/alotMatter.json", params={ + "crtfc_key": DART_API_KEY, + "corp_code": corp["corp_code"], + "bsns_year": bsns_year, + "reprt_code": "11011", # 사업보고서 + }) + data = resp.json() + if data.get("status") != "000" or not data.get("list"): + continue + + # DART alotMatter 응답 파싱 (응답 키: thstrm/frmtrm, '-'는 0으로 처리) + def _to_int(v: str) -> int: + s = str(v or "").replace(",", "").strip() + if not s or s == "-": + return 0 + try: + return int(float(s)) + except: return 0 + + dps = dps_prev = total_div = 0 + for item in data["list"]: + se = item.get("se", "") + th = item.get("thstrm", "") + fr = item.get("frmtrm", "") + if "주당 현금배당금" in se and dps == 0: + dps = _to_int(th) + dps_prev = _to_int(fr) + elif "현금배당금총액" in se and total_div == 0: + total_div = _to_int(th) * 1_000_000 # 백만원 → 원 + if dps <= 0: + continue + async with pg_pool.acquire() as conn: + await conn.execute(""" + INSERT INTO dart_dividends + (stock_code, corp_name, bsns_year, dps, dps_prev, total_dividend) + VALUES ($1,$2,$3,$4,$5,$6) + ON CONFLICT (stock_code, bsns_year) + DO UPDATE SET dps=$4, dps_prev=$5, total_dividend=$6, collected_at=NOW() + """, corp["stock_code"], corp["corp_name"], bsns_year, + dps, dps_prev, total_div) + saved += 1 + await asyncio.sleep(0.15) + except Exception as e: + logger.warning("dividends.fetch_err", corp=corp["stock_code"], error=str(e)) + + logger.info("dividends.done", saved=saved) + return saved + + +# ══════════════════════════════════════════════════════════ +# 6. 섹터 정보 수집 (KRX CSV) +# ══════════════════════════════════════════════════════════ + +_KSIC_MAP = { + "10":"식품","11":"음료","12":"담배","13":"섬유","14":"의복/모피","15":"가죽/신발", + "16":"목재","17":"펄프/종이","18":"인쇄","19":"석유정제","20":"화학", + "21":"의약품","22":"고무/플라스틱","23":"비금속광물","24":"1차금속","25":"금속가공", + "26":"전자/반도체","27":"의료광학","28":"전기장비","29":"기계","30":"자동차", + "31":"기타운송장비","32":"가구","33":"기타제조","34":"산업기계수리","35":"전기/가스","36":"수도", + "37":"하수처리","38":"폐기물","39":"환경복원","41":"건설(종합)","42":"건설(전문)", + "45":"자동차판매","46":"도매","47":"소매","49":"육상운송","50":"수상운송", + "51":"항공운송","52":"창고/물류","55":"숙박","56":"음식/음료","58":"출판", + "59":"영상/음악","60":"방송","61":"통신","62":"IT서비스","63":"정보서비스", + "64":"금융","65":"보험","66":"금융보조","68":"부동산","70":"연구개발", + "71":"전문서비스","72":"건축/엔지니어링","73":"기술시험","74":"기타전문", + "75":"수의업","76":"임대","77":"인력공급","78":"여행","79":"경비/청소", + "80":"콜센터","82":"기타사업","84":"공공행정","85":"교육","86":"보건", + "87":"사회복지","90":"창작/예술","91":"스포츠/여가","96":"기타서비스", +} + +async def collect_sectors(): + """DART company API로 업종 수집 (재무데이터 보유 종목 대상) + sector_name (KSIC 한글) + sector_code (KSIC 2자리) + sector (score-engine 읽음)에 저장 + """ + logger.info("sectors.collecting") + async with pg_pool.acquire() as conn: + corps = await conn.fetch(""" + SELECT stock_code, corp_code FROM dart_corps + WHERE is_active = true AND corp_code != '' + AND (sector IS NULL OR sector = '') + ORDER BY stock_code LIMIT 5000 + """) + updated = 0 + async with httpx.AsyncClient(timeout=15) as client: + for corp in corps: + try: + resp = await client.get(f"{DART_BASE}/company.json", params={ + "crtfc_key": DART_API_KEY, "corp_code": corp["corp_code"] + }) + d = resp.json() + if d.get("status") != "000": + continue + code = str(d.get("induty_code", "") or "") + sector = _KSIC_MAP.get(code[:2], "") + if not sector and code: + sector = code # 매핑 없으면 코드 그대로 + if sector: + async with pg_pool.acquire() as conn: + await conn.execute( + "UPDATE dart_corps SET sector_name=$1, sector_code=$2, sector=$1 WHERE stock_code=$3", + sector, code, corp["stock_code"]) + updated += 1 + await asyncio.sleep(0.1) + except Exception as e: + logger.debug("sectors.corp_err", corp=corp["stock_code"], error=str(e)) + logger.info("sectors.done", updated=updated) + return updated + + +# ══════════════════════════════════════════════════════════ +# 임원·대주주 매매 수집 +# ══════════════════════════════════════════════════════════ + +def _parse_int_safe(v) -> int: + """'1,330' / '-205,875' / '-' → int""" + if not v or v == "-": + return 0 + try: + return int(str(v).replace(",", "").replace(" ", "")) + except Exception: + return 0 + + +def _parse_float_safe(v) -> float: + if not v or v == "-": + return 0.0 + try: + return float(str(v).replace(",", "").replace("%", "").replace(" ", "")) + except Exception: + return 0.0 + + +async def fetch_insider_for_corp(client: httpx.AsyncClient, corp_code: str + ) -> tuple[list, list]: + """elestock(임원) + majorstock(대량보유) 동시 조회""" + base = "https://opendart.fss.or.kr/api" + out_elestock, out_major = [], [] + try: + r1 = await client.get(f"{base}/elestock.json", params={ + "crtfc_key": DART_API_KEY, "corp_code": corp_code}, timeout=15) + j = r1.json() + if j.get("status") == "000": + out_elestock = j.get("list") or [] + except Exception as e: + logger.debug("insider.elestock_err", corp=corp_code, err=str(e)) + try: + r2 = await client.get(f"{base}/majorstock.json", params={ + "crtfc_key": DART_API_KEY, "corp_code": corp_code}, timeout=15) + j = r2.json() + if j.get("status") == "000": + out_major = j.get("list") or [] + except Exception as e: + logger.debug("insider.major_err", corp=corp_code, err=str(e)) + return out_elestock, out_major + + +async def collect_insider_trades(count: int = 500): + """시총 상위 N개 종목의 임원·대주주 매매 보고 수집. + DART rate-limit 고려해 1초당 2건 (분당 120).""" + async with pg_pool.acquire() as conn: + # 시총 상위 종목 (stock_prices 최신) + rows = await conn.fetch(""" + SELECT DISTINCT ON (d.stock_code) d.stock_code, d.corp_code, + COALESCE(p.market_cap, 0) AS mc + FROM dart_corps d + LEFT JOIN stock_prices p ON p.stock_code=d.stock_code + WHERE d.is_active=true + ORDER BY d.stock_code, p.collected_at DESC NULLS LAST + """) + top = sorted(rows, key=lambda r: -r["mc"])[:count] + logger.info("insider.collect_start", count=len(top)) + + saved_e, saved_m = 0, 0 + async with httpx.AsyncClient() as client: + for i, row in enumerate(top): + ele, maj = await fetch_insider_for_corp(client, row["corp_code"]) + for r in ele: + try: + await conn.execute(""" + INSERT INTO dart_insider_trades + (rcept_no, stock_code, corp_code, rcept_dt, + repror, ofcps, main_shrholdr, + shares_change, shares_after, rate_after, source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'elestock') + ON CONFLICT (rcept_no) DO NOTHING + """, r.get("rcept_no"), row["stock_code"], row["corp_code"], + datetime.strptime(r["rcept_dt"], "%Y-%m-%d").date() + if r.get("rcept_dt") else None, + r.get("repror", ""), r.get("isu_exctv_ofcps", ""), + r.get("isu_main_shrholdr", ""), + _parse_int_safe(r.get("sp_stock_lmp_irds_cnt")), + _parse_int_safe(r.get("sp_stock_lmp_cnt")), + _parse_float_safe(r.get("sp_stock_lmp_rate"))) + saved_e += 1 + except Exception as e: + logger.debug("insider.ele_save_err", err=str(e)) + for r in maj: + try: + await conn.execute(""" + INSERT INTO dart_insider_trades + (rcept_no, stock_code, corp_code, rcept_dt, + repror, ofcps, main_shrholdr, + shares_change, shares_after, rate_after, source) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'majorstock') + ON CONFLICT (rcept_no) DO NOTHING + """, r.get("rcept_no"), row["stock_code"], row["corp_code"], + datetime.strptime(r["rcept_dt"], "%Y-%m-%d").date() + if r.get("rcept_dt") else None, + r.get("repror", ""), r.get("report_tp", ""), + "5%이상", + _parse_int_safe(r.get("stkqy_irds")), + _parse_int_safe(r.get("stkqy")), + _parse_float_safe(r.get("stkrt"))) + saved_m += 1 + except Exception as e: + logger.debug("insider.maj_save_err", err=str(e)) + # DART rate limit: 분당 1000건 OK. 0.5초 = 분당 120건 안전 + if i < len(top) - 1: + await asyncio.sleep(0.5) + logger.info("insider.collect_done", elestock=saved_e, majorstock=saved_m) + return {"elestock": saved_e, "majorstock": saved_m} + + +# ══════════════════════════════════════════════════════════ +# DB 테이블 초기화 +# ══════════════════════════════════════════════════════════ + +async def init_db(): + """필요한 테이블 생성""" + async with pg_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS dart_corps ( + stock_code VARCHAR(10) PRIMARY KEY, + corp_code VARCHAR(20) NOT NULL, + corp_name VARCHAR(200) NOT NULL, + modify_date VARCHAR(20) DEFAULT '', + is_active BOOLEAN DEFAULT FALSE + ) + """) + try: + await conn.execute( + "ALTER TABLE dart_corps ADD COLUMN is_active BOOLEAN DEFAULT FALSE") + except: pass + for col in ["sector_name VARCHAR(100) DEFAULT ''", + "sector_code VARCHAR(20) DEFAULT ''"]: + try: + await conn.execute(f"ALTER TABLE dart_corps ADD COLUMN {col}") + except: pass + await conn.execute(""" + CREATE TABLE IF NOT EXISTS dart_disclosures ( + id SERIAL, + rcept_no VARCHAR(20) PRIMARY KEY, + rcept_dt VARCHAR(10), + corp_code VARCHAR(20), + corp_name VARCHAR(200), + stock_code VARCHAR(10), + corp_cls VARCHAR(5), + report_nm TEXT, + flr_nm VARCHAR(200), + pblntf_ty VARCHAR(5), + pblntf_detail_ty VARCHAR(5), + collected_at TIMESTAMP DEFAULT NOW() + ) + """) + await conn.execute("CREATE INDEX IF NOT EXISTS idx_disc_stock ON dart_disclosures(stock_code)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_disc_date ON dart_disclosures(rcept_dt DESC)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_disc_type ON dart_disclosures(pblntf_ty)") + # 임원·대주주 매매 보고 + await conn.execute(""" + CREATE TABLE IF NOT EXISTS dart_insider_trades ( + id SERIAL PRIMARY KEY, + rcept_no VARCHAR(20) UNIQUE, + stock_code VARCHAR(10), + corp_code VARCHAR(20), + rcept_dt DATE, + repror VARCHAR(200), + ofcps VARCHAR(100) DEFAULT '', + main_shrholdr VARCHAR(50) DEFAULT '', + shares_change BIGINT DEFAULT 0, + shares_after BIGINT DEFAULT 0, + rate_after FLOAT DEFAULT 0, + source VARCHAR(20), + collected_at TIMESTAMP DEFAULT NOW() + ) + """) + await conn.execute("CREATE INDEX IF NOT EXISTS idx_insider_stock ON dart_insider_trades(stock_code, rcept_dt DESC)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_insider_date ON dart_insider_trades(rcept_dt DESC)") + await conn.execute(""" + CREATE TABLE IF NOT EXISTS dart_financials ( + id SERIAL, + stock_code VARCHAR(10) NOT NULL, + corp_code VARCHAR(20), + corp_name VARCHAR(200), + bsns_year VARCHAR(4) NOT NULL, + reprt_code VARCHAR(10) NOT NULL, + reprt_name VARCHAR(50), + revenue BIGINT DEFAULT 0, + operating_profit BIGINT DEFAULT 0, + net_income BIGINT DEFAULT 0, + total_assets BIGINT DEFAULT 0, + total_liabilities BIGINT DEFAULT 0, + total_equity BIGINT DEFAULT 0, + operating_cashflow BIGINT DEFAULT 0, + collected_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, bsns_year, reprt_code) + ) + """) + await conn.execute("CREATE INDEX IF NOT EXISTS idx_fin_stock ON dart_financials(stock_code)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_fin_year ON dart_financials(bsns_year DESC)") + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS dart_dividends ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + corp_name VARCHAR(200) DEFAULT '', + bsns_year VARCHAR(4) NOT NULL, + dps BIGINT DEFAULT 0, + dps_prev BIGINT DEFAULT 0, + dividend_yield FLOAT DEFAULT 0, + total_dividend BIGINT DEFAULT 0, + collected_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, bsns_year) + ) + """) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_div_stock ON dart_dividends(stock_code)") + logger.info("db.initialized") + +# ══════════════════════════════════════════════════════════ +# FastAPI 앱 +# ══════════════════════════════════════════════════════════ + +app = FastAPI(title="DART 공시 수집기", version="1.0.0") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + + +@app.on_event("startup") +async def startup(): + global pg_pool, redis_client + + # PostgreSQL + pg_pool = await asyncpg.create_pool( + host=PG_HOST, port=PG_PORT, database=PG_DB, + user=PG_USER, password=PG_PASS, min_size=2, max_size=10, + ) + await init_db() + + # Redis + redis_client = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, + db=5, decode_responses=True, + ) + + # 기업코드 로딩 + await load_corp_codes() + + # 스케줄러 + # 공시 수집: 평일 08:00~18:00 / 10분마다 + scheduler.add_job(collect_recent_disclosures, "cron", + day_of_week="mon-fri", hour="8-18", minute="*/10", + id="disclosures", replace_existing=True) + + # 주요사항보고: 평일 09:00, 12:00, 15:00 + scheduler.add_job(collect_major_reports, "cron", + day_of_week="mon-fri", hour="9,12,15", + id="major_reports", replace_existing=True) + + # 재무제표: 매일 새벽 3시 + scheduler.add_job(collect_financials_for_top_stocks, "cron", + hour=3, id="financials", replace_existing=True) + + # 배당: 매주 일요일 04:00 (사업보고서 공시 직후 4월/연중 갱신) + scheduler.add_job(collect_dividends, "cron", + day_of_week="sun", hour=4, id="dividends", replace_existing=True) + + # 섹터: 매주 일요일 05:00 + scheduler.add_job(collect_sectors, "cron", + day_of_week="sun", hour=5, id="sectors", replace_existing=True) + + # 기업코드 갱신: 매일 새벽 2시 + scheduler.add_job(load_corp_codes, "cron", + hour=2, id="corp_codes", replace_existing=True) + + # 임원·대주주 매매: 매일 새벽 6시 (시총상위 500개) + scheduler.add_job(collect_insider_trades, "cron", + hour=6, id="insider", replace_existing=True) + + scheduler.start() + + # 주간 섹터 잡(일 05:00)이 컨테이너 다운/재배포로 누락되면 catch-up 없음 → + # 섹터 노후/빈값이 score-engine 전 시장 4종목 붕괴를 유발. 실패모드(낮은 채움률)를 + # 직접 겨냥: startup 시 활성종목 섹터 채움률<90%면 1회 백필 (deep_batch와 동일 패턴). + async def _sectors_catchup(): + async with pg_pool.acquire() as conn: + fill = await conn.fetchval(""" + SELECT COALESCE( + COUNT(*) FILTER (WHERE sector IS NOT NULL AND sector <> '')::float + / NULLIF(COUNT(*), 0), 0) + FROM dart_corps WHERE is_active=true + """) + if fill < 0.9: + logger.info("sectors.catchup_start", fill=round(float(fill), 3)) + await collect_sectors() + asyncio.create_task(_sectors_catchup()) + + logger.info("dart.started", corps=len(corp_codes)) + + +@app.on_event("shutdown") +async def shutdown(): + scheduler.shutdown() + if pg_pool: await pg_pool.close() + if redis_client: await redis_client.aclose() + + +@app.get("/health") +async def health(): + return JSONResponse(content={ + "status": "ok", + "dart_api_key": "set" if DART_API_KEY else "missing", + "corps_loaded": stats.corps_loaded, + "disclosures_collected": stats.disclosures, + "financials_collected": stats.financials, + "last_run": stats.last_run, + "errors": stats.errors, + }) + + +@app.post("/collect/disclosures") +async def manual_disclosures(): + asyncio.create_task(collect_recent_disclosures()) + return {"status": "started"} + + +@app.post("/collect/financials") +async def manual_financials( + count: int = Query(default=50), + years: int = Query(default=2, ge=1, le=20), + annual_only: bool = Query(default=False), +): + asyncio.create_task(collect_financials_for_top_stocks(count, years, annual_only)) + return {"status": "started", "count": count, "years": years, "annual_only": annual_only} + + +@app.post("/collect/sectors") +async def manual_sectors(): + n = await collect_sectors() + return {"status": "done", "saved": n} + + +@app.post("/collect/major") +async def manual_major(): + asyncio.create_task(collect_major_reports()) + return {"status": "started"} + + +@app.post("/collect/corps") +async def manual_corps(): + await load_corp_codes() + return {"status": "ok", "count": len(corp_codes)} + + +@app.post("/collect/insider") +async def manual_insider(count: int = Query(default=500, ge=10, le=2000)): + asyncio.create_task(collect_insider_trades(count)) + return {"status": "started", "count": count} + + +@app.get("/insider/stats") +async def insider_stats(): + async with pg_pool.acquire() as conn: + r = await conn.fetchrow(""" + SELECT COUNT(*) AS total, + COUNT(DISTINCT stock_code) AS stocks, + SUM(CASE WHEN shares_change > 0 THEN 1 ELSE 0 END) AS buys, + SUM(CASE WHEN shares_change < 0 THEN 1 ELSE 0 END) AS sells, + MAX(rcept_dt) AS latest + FROM dart_insider_trades + """) + return dict(r) if r else {} + + +@app.get("/insider/{stock_code}") +async def get_insider(stock_code: str, days: int = Query(default=90, ge=1, le=365)): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT rcept_dt, repror, ofcps, main_shrholdr, + shares_change, shares_after, rate_after, source + FROM dart_insider_trades + WHERE stock_code=$1 AND rcept_dt >= CURRENT_DATE - $2::int + ORDER BY rcept_dt DESC LIMIT 100 + """, stock_code, days) + return [dict(r) for r in rows] + + +@app.get("/corps") +async def list_corps(limit: int = Query(default=50)): + items = list(corp_codes.values())[:limit] + return JSONResponse(content={"count": len(corp_codes), "items": items}) + + +@app.get("/corps/{stock_code}") +async def get_corp(stock_code: str): + info = corp_codes.get(stock_code) + if not info: + raise HTTPException(404, "종목 없음") + return JSONResponse(content=info) + + +@app.get("/disclosures") +async def list_disclosures( + stock_code: str = Query(default=""), + days: int = Query(default=7), + limit: int = Query(default=50), +): + async with pg_pool.acquire() as conn: + if stock_code: + rows = await conn.fetch(""" + SELECT * FROM dart_disclosures + WHERE stock_code = $1 AND rcept_dt >= $2 + ORDER BY rcept_dt DESC LIMIT $3 + """, stock_code, (datetime.now() - timedelta(days=days)).strftime("%Y%m%d"), limit) + else: + rows = await conn.fetch(""" + SELECT * FROM dart_disclosures + WHERE rcept_dt >= $1 + ORDER BY rcept_dt DESC LIMIT $2 + """, (datetime.now() - timedelta(days=days)).strftime("%Y%m%d"), limit) + return [dict(r) for r in rows] + + +@app.get("/financials/{stock_code}") +async def get_financials(stock_code: str): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT * FROM dart_financials + WHERE stock_code = $1 + ORDER BY bsns_year DESC, reprt_code DESC + """, stock_code) + return [dict(r) for r in rows] + + +@app.post("/collect/dividends") +async def manual_dividends(count: int = Query(default=200)): + n = await collect_dividends(count) + return {"status": "done", "saved": n} + +@app.post("/collect/sectors") +async def manual_sectors(): + asyncio.create_task(collect_sectors()) + return {"status": "started"} + +@app.get("/dividends/{stock_code}") +async def get_dividends(stock_code: str): + async with pg_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM dart_dividends WHERE stock_code=$1 ORDER BY bsns_year DESC", stock_code) + return [dict(r) for r in rows] + +@app.get("/stats") +async def get_stats(): + counts = {} + if pg_pool: + async with pg_pool.acquire() as conn: + counts["corps"] = await conn.fetchval("SELECT COUNT(*) FROM dart_corps") + counts["disclosures"] = await conn.fetchval("SELECT COUNT(*) FROM dart_disclosures") + counts["financials"] = await conn.fetchval("SELECT COUNT(*) FROM dart_financials") + counts["news_from_dart"] = await conn.fetchval( + "SELECT COUNT(*) FROM news_analysis WHERE source = 'DART공시'" + ) + return JSONResponse(content=counts) diff --git a/dart-collector/requirements.txt b/dart-collector/requirements.txt new file mode 100644 index 0000000..aa95fc7 --- /dev/null +++ b/dart-collector/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +httpx==0.27.0 +redis==5.0.4 +asyncpg==0.29.0 +apscheduler==3.10.4 +orjson==3.10.3 +structlog==24.2.0 +xmltodict==0.13.0 +openpyxl==3.1.2 +zipfile36==0.1.3 diff --git a/dashboard-api/Dockerfile b/dashboard-api/Dockerfile new file mode 100644 index 0000000..96a6ed6 --- /dev/null +++ b/dashboard-api/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install fastapi uvicorn asyncpg redis orjson httpx pydantic \ + bcrypt "python-jose[cryptography]" email-validator apscheduler +COPY . . +EXPOSE 8989 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8989", "--workers", "1"] diff --git a/dashboard-api/auth.py b/dashboard-api/auth.py new file mode 100644 index 0000000..05dbba7 --- /dev/null +++ b/dashboard-api/auth.py @@ -0,0 +1,59 @@ +"""인증 유틸 - bcrypt 해시 + JWT 토큰 (이메일/비밀번호)""" +import os +from datetime import datetime, timedelta, timezone +import bcrypt +from jose import jwt, JWTError +from fastapi import Header, HTTPException, status + +JWT_SECRET = os.getenv("JWT_SECRET", "") +JWT_ALGORITHM = "HS256" +JWT_EXPIRE_DAYS = int(os.getenv("JWT_EXPIRE_DAYS", "7")) + +if not JWT_SECRET: + raise RuntimeError("JWT_SECRET 환경변수가 설정되지 않았습니다") + +def hash_password(plain: str) -> str: + # bcrypt는 72바이트 제한 — 안전을 위해 잘라서 해시 + pw_bytes = plain.encode("utf-8")[:72] + return bcrypt.hashpw(pw_bytes, bcrypt.gensalt()).decode("utf-8") + +def verify_password(plain: str, hashed: str) -> bool: + try: + pw_bytes = plain.encode("utf-8")[:72] + return bcrypt.checkpw(pw_bytes, hashed.encode("utf-8")) + except Exception: + return False + +def create_token(user_id: int, email: str) -> str: + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "email": email, + "iat": int(now.timestamp()), + "exp": int((now + timedelta(days=JWT_EXPIRE_DAYS)).timestamp()), + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + +def decode_token(token: str) -> dict: + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except JWTError as e: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=f"invalid token: {e}") + +async def current_user(authorization: str = Header(default="")) -> dict: + """Authorization: Bearer → {"id": int, "email": str}""" + if not authorization.lower().startswith("bearer "): + raise HTTPException(status_code=401, detail="missing bearer token") + token = authorization.split(" ", 1)[1].strip() + payload = decode_token(token) + try: + return {"id": int(payload["sub"]), "email": payload.get("email", "")} + except (KeyError, ValueError): + raise HTTPException(status_code=401, detail="malformed token payload") + +# 타이밍 공격 방지용 더미 해시 (이메일이 없을 때도 verify를 호출해 응답시간 균일화) +_DUMMY_HASH = bcrypt.hashpw(b"dummy_password_for_timing", bcrypt.gensalt()).decode("utf-8") + +def dummy_verify(): + """존재하지 않는 사용자에 대해서도 bcrypt 비용을 동일하게 지불""" + bcrypt.checkpw(b"dummy_password_for_timing", _DUMMY_HASH.encode("utf-8")) diff --git a/dashboard-api/cards.html b/dashboard-api/cards.html new file mode 100644 index 0000000..b3714ff --- /dev/null +++ b/dashboard-api/cards.html @@ -0,0 +1,292 @@ + + + + + +Trading AI · 종목 카드 + + + +
+

📊 Trading AI · 종목 카드

+
로딩…
+
+ +
+ +
+
🟢 매수 추천
+
🔴 회피 종목
+
+ +
+
불러오는 중…
+
+ + + + diff --git a/dashboard-api/index.html b/dashboard-api/index.html new file mode 100644 index 0000000..b82213c --- /dev/null +++ b/dashboard-api/index.html @@ -0,0 +1,1727 @@ + + + + + +Trading AI Dashboard + + + + + + + +
+ +
+ + + +
+
+
◆ AI 애널리스트
exaone3.5 · 버핏 스타일 분석
+ +
+
+
안녕하세요! 오늘 시장 현황이나 종목에 대해 물어보세요.
예: "오늘 추천종목 알려줘" / "SK하이닉스 분석해줘"
+
+
+ + +
+
+ +
+
+

Trading AI Dashboard

뉴스 + DART 공시 기반 종목 분석 시스템
+
+
+
+ +
+
+
+ + +
+ +
+ + + + + + + + + + +
+
+ + + + + + + + + + +
+ + + + + + + + + + diff --git a/dashboard-api/main.py b/dashboard-api/main.py new file mode 100644 index 0000000..c6e9ab2 --- /dev/null +++ b/dashboard-api/main.py @@ -0,0 +1,2005 @@ +"""대시보드 API - 프론트엔드에서 호출""" +import asyncio, os, json, re +from datetime import datetime, timedelta, timezone +import asyncpg, httpx, redis.asyncio as aioredis +from fastapi import FastAPI, Query, Depends, HTTPException, Request +from fastapi.responses import JSONResponse, FileResponse, StreamingResponse +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel, EmailStr, Field +from auth import hash_password, verify_password, create_token, current_user, dummy_verify + +TA_ENGINE_URL = os.getenv("TA_ENGINE_URL", "http://ta-engine:8484") +KIS_API_URL = os.getenv("KIS_API_URL", "http://kis-api:8585") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") +CHAT_MODEL = os.getenv("CHAT_MODEL", "exaone3.5:7.8b") + +class PositionReq(BaseModel): + code: str + name: str = "" + buy_price: int + qty: int + +PG_HOST = os.getenv("POSTGRES_HOST", "postgres") +PG_PORT = int(os.getenv("POSTGRES_PORT", "5432")) +PG_DB = os.getenv("POSTGRES_DB", "trading_ai") +PG_USER = os.getenv("POSTGRES_USER", "kyu") +PG_PASS = os.getenv("POSTGRES_PASSWORD", "") +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") + +pg_pool = None +redis_cl = None + +ADMIN_EMAILS = { + e.strip().lower() for e in os.getenv("ADMIN_EMAILS", "").split(",") if e.strip() +} +TRUSTED_ORIGINS = [ + o.strip() for o in os.getenv("TRUSTED_ORIGINS", "").split(",") if o.strip() +] or ["http://localhost:8989"] + +# 레이트 리밋: IP당 시도 수 (Redis db=2 사용) +RL_LOGIN_MAX = 10 # 15분 내 10회 +RL_LOGIN_WINDOW = 900 +RL_REGISTER_MAX = 5 # 1시간 내 5회 +RL_REGISTER_WINDOW = 3600 +LOCK_THRESHOLD = 5 # 실패 5회 시 잠금 +LOCK_DURATION = 900 # 15분 + +app = FastAPI(title="Dashboard API") +app.add_middleware( + CORSMiddleware, + allow_origins=TRUSTED_ORIGINS, + allow_credentials=False, + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["Authorization", "Content-Type"], +) + +@app.middleware("http") +async def security_headers(request: Request, call_next): + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Permissions-Policy"] = "geolocation=(), camera=(), microphone=()" + if request.url.scheme == "https": + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + return response + +# 인증용 별도 Redis (db=2) +auth_redis = None + +def _client_ip(request: Request) -> str: + xff = request.headers.get("x-forwarded-for", "") + if xff: + return xff.split(",")[0].strip() + return request.client.host if request.client else "unknown" + +async def _rate_limit(key: str, limit: int, window: int): + if not auth_redis: + return + try: + cnt = await auth_redis.incr(key) + if cnt == 1: + await auth_redis.expire(key, window) + if cnt > limit: + ttl = await auth_redis.ttl(key) + raise HTTPException( + status_code=429, + detail=f"요청이 너무 많습니다. {max(ttl,1)}초 후 다시 시도하세요") + except HTTPException: + raise + except Exception: + pass # Redis 장애 시 게이트만 우회 (보안 vs 가용성 트레이드오프) + +@app.on_event("startup") +async def startup(): + global pg_pool, redis_cl, auth_redis + pg_pool = await asyncpg.create_pool( + host=PG_HOST, port=PG_PORT, database=PG_DB, + user=PG_USER, password=PG_PASS, min_size=2, max_size=5) + redis_cl = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True) + auth_redis = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=2, decode_responses=True) + # 워치리스트 + 알림 테이블 초기화 + async with pg_pool.acquire() as c: + await c.execute(""" + CREATE TABLE IF NOT EXISTS user_watchlist ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + stock_code VARCHAR(10) NOT NULL, + memo VARCHAR(200) DEFAULT '', + added_at TIMESTAMP DEFAULT NOW(), + UNIQUE(user_id, stock_code) + ) + """) + await c.execute( + "CREATE INDEX IF NOT EXISTS idx_watchlist_user ON user_watchlist(user_id)") + await c.execute(""" + CREATE TABLE IF NOT EXISTS user_alerts ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + stock_code VARCHAR(10) NOT NULL, + alert_type VARCHAR(30) NOT NULL, + threshold FLOAT NOT NULL, + active BOOLEAN DEFAULT TRUE, + last_triggered TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW() + ) + """) + await c.execute( + "CREATE INDEX IF NOT EXISTS idx_alerts_active ON user_alerts(active) WHERE active=true") + # 알림 cron — 5분마다 활성 알림 체크 + if not hasattr(app.state, 'alert_scheduler'): + from apscheduler.schedulers.asyncio import AsyncIOScheduler + app.state.alert_scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + app.state.alert_scheduler.add_job( + check_alerts, "cron", day_of_week="mon-fri", + hour="9-15", minute="*/5", id="alerts_check", replace_existing=True) + app.state.alert_scheduler.start() + +@app.on_event("shutdown") +async def shutdown(): + if pg_pool: await pg_pool.close() + if redis_cl: await redis_cl.aclose() + if auth_redis: await auth_redis.aclose() + +# ── 응답 정리 helper (소수점·중복·잘림) ───────────────────── + +def _r1(v): + """소수 1자리 반올림 (None 안전)""" + if v is None: return 0.0 + try: return round(float(v), 1) + except: return 0.0 + +def _clean_reasons(raw: str, max_items: int = 4, max_len: int = 60) -> list: + """ + top_reasons " | " 분리 → 중복 제거(앞 30자 기준) + 잘림 명시 + """ + if not raw: + return [] + parts = [p.strip() for p in raw.split("|") if p.strip()] + seen = set() + out = [] + for p in parts: + key = p[:30].strip() + if key in seen: continue + seen.add(key) + if len(p) > max_len: + p = p[:max_len].rstrip() + "…" + out.append(p) + if len(out) >= max_items: break + return out + +async def _enrich_rec(c, redis_cl, code: str, base: dict) -> dict: + """ + 추천 종목 dict에 매매가/포지션/재무/현재가/수급 통합 채움 + """ + # 1) Redis 현재가 + price = ch = mc = per = pbr = 0 + try: + raw = await redis_cl.get(f"price:{code}") + if raw: + p = json.loads(raw) + price = int(p.get("price") or 0) + ch = float(p.get("change_pct") or 0) + mc = int(p.get("market_cap") or 0) + per = float(p.get("per") or 0) + pbr = float(p.get("pbr") or 0) + except: pass + + # 2) stock_scores 최신 (DCF/추세/포지션/안전마진/시장레짐/섹터) + sc_row = await c.fetchrow(""" + SELECT trend_score, intrinsic_value, margin_of_safety, earnings_quality, + position_size_pct, volatility_60d, market_regime_adj, sector, + foreign_score, short_score, foreign_ratio, short_weight + FROM stock_scores WHERE stock_code=$1 + ORDER BY score_date DESC LIMIT 1 + """, code) + + # 3) ta-engine 결과 (목표가/손절/ATR trailing) + targets = {} + ta_row = await c.fetchrow(""" + SELECT targets, signal AS ta_signal, tech_score + FROM stock_technical WHERE stock_code=$1 + """, code) + if ta_row and ta_row["targets"]: + try: + t = ta_row["targets"] + targets = json.loads(t) if isinstance(t, str) else dict(t) + except: targets = {} + + # 4) 재무 (최신 사업보고서) + fin_row = await c.fetchrow(""" + SELECT roe, operating_margin, debt_ratio, fcf_ratio, + revenue_growth, bsns_year + FROM dart_financials WHERE stock_code=$1 AND reprt_code='11011' + ORDER BY bsns_year DESC LIMIT 1 + """, code) + + base["score"] = _r1(base.get("total_score")) + base["news_score"] = _r1(base.get("news_score")) + base["technical_score"] = _r1(base.get("technical_score")) + base["dart_score"] = _r1(base.get("dart_score")) + base["price"] = price + base["change_pct"] = _r1(ch) + base["market_cap_eok"] = round(mc / 100_000_000) if mc else 0 + base["per"] = _r1(per) + base["pbr"] = round(pbr, 2) if pbr else 0 + base["reasons"] = _clean_reasons(base.pop("top_reasons", "") or "") + base["entry_price"] = targets.get("entry_price", 0) + base["t1"] = targets.get("t1", 0) + base["t2"] = targets.get("t2", 0) + base["t3"] = targets.get("t3", 0) + base["t1_pct"] = _r1(targets.get("t1_pct")) + base["t2_pct"] = _r1(targets.get("t2_pct")) + base["t3_pct"] = _r1(targets.get("t3_pct")) + base["stop_loss"] = targets.get("stop_loss", 0) + base["trailing_stop"] = targets.get("trailing_stop", 0) + base["atr14"] = targets.get("atr14", 0) + base["exit_strategy"] = targets.get("exit_strategy", "") + + if sc_row: + base["trend_score"] = _r1(sc_row["trend_score"]) + base["intrinsic_value"] = sc_row["intrinsic_value"] or 0 + base["margin_of_safety"] = _r1(sc_row["margin_of_safety"]) + base["earnings_quality"] = _r1(sc_row["earnings_quality"]) + base["position_size_pct"] = _r1(sc_row["position_size_pct"]) + base["volatility_60d"] = _r1(sc_row["volatility_60d"]) + base["market_regime_adj"] = _r1(sc_row["market_regime_adj"]) + base["sector"] = sc_row["sector"] or "(미분류)" + base["foreign_score"] = _r1(sc_row["foreign_score"]) + base["short_score"] = _r1(sc_row["short_score"]) + base["foreign_ratio_pct"] = _r1(sc_row["foreign_ratio"]) + base["short_weight_pct"] = _r1(sc_row["short_weight"]) + + if fin_row: + base["roe"] = _r1(fin_row["roe"]) + base["operating_margin"] = _r1(fin_row["operating_margin"]) + base["debt_ratio"] = _r1(fin_row["debt_ratio"]) + base["fcf_ratio"] = _r1(fin_row["fcf_ratio"]) + base["revenue_growth"] = _r1(fin_row["revenue_growth"]) + base["bsns_year"] = fin_row["bsns_year"] + + if "total_score" in base: del base["total_score"] + if "price_score" in base: del base["price_score"] + if "recommended_at" in base: + base["recommended_at"] = str(base["recommended_at"]) + return base + + +# ── 요약 ──────────────────────────────────────────────────── + +@app.get("/api/summary") +async def summary(): + async with pg_pool.acquire() as c: + total = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE analyzed_at>=CURRENT_DATE-7") + pos = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE sentiment='호재' AND analyzed_at>=CURRENT_DATE-7") + neg = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE sentiment='악재' AND analyzed_at>=CURRENT_DATE-7") + dart = await c.fetchval("SELECT COUNT(*) FROM news_analysis WHERE source='DART공시' AND analyzed_at>=CURRENT_DATE-7") + stocks = await c.fetchval("SELECT COUNT(DISTINCT primary_stock) FROM news_analysis WHERE primary_stock!='' AND analyzed_at>=CURRENT_DATE-7") + signals = await c.fetchval("SELECT COUNT(*) FROM trade_signals WHERE created_at>=CURRENT_DATE-1") + regime = await c.fetchrow("SELECT regime, regime_adj FROM market_regime ORDER BY dt DESC LIMIT 1") + rec_count = await c.fetchrow(""" + SELECT COUNT(*) FILTER (WHERE recommendation='강력매수') AS strong_buy, + COUNT(*) FILTER (WHERE recommendation='매수관심') AS interest_buy, + COUNT(*) FILTER (WHERE recommendation='매도관심') AS interest_sell, + COUNT(*) FILTER (WHERE recommendation='강력매도') AS strong_sell + FROM stock_scores WHERE score_date=CURRENT_DATE + """) + return { + "total": total, "positive": pos, "negative": neg, + "dart": dart, "stocks_analyzed": stocks, + "signals_today": signals, + "sentiment_ratio": round(pos / (pos + neg) * 100 if pos + neg > 0 else 50, 1), + "market_regime": regime["regime"] if regime else "데이터부족", + "market_regime_adj": _r1(regime["regime_adj"]) if regime else 0, + "strong_buy": rec_count["strong_buy"] if rec_count else 0, + "interest_buy": rec_count["interest_buy"] if rec_count else 0, + "interest_sell": rec_count["interest_sell"] if rec_count else 0, + "strong_sell": rec_count["strong_sell"] if rec_count else 0, + } + +# ── 최근 뉴스 ──────────────────────────────────────────────── + +@app.get("/api/recent") +async def recent(limit: int = Query(default=40), only_stock: bool = Query(default=True)): + """only_stock=true(기본): 종목/시장 매칭된 뉴스만. false: 전체.""" + where = "" + if only_stock: + where = """WHERE (primary_stock IS NOT NULL AND primary_stock <> '') + OR (stock_codes IS NOT NULL AND jsonb_array_length(stock_codes) > 0) + OR (catalyst IS NOT NULL AND catalyst <> '')""" + async with pg_pool.acquire() as c: + rows = await c.fetch(f""" + SELECT title, sentiment, intensity, primary_stock, reason, + investment_action, source, analyzed_at, url, catalyst, stock_codes + FROM news_analysis {where} + ORDER BY analyzed_at DESC LIMIT $1 + """, limit) + return [dict(r) for r in rows] + +# ── 종목 랭킹 ───────────────────────────────────────────────── + +@app.get("/api/ranking") +async def ranking(): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT s.stock_code, s.stock_name, s.total_score, s.recommendation, + s.news_score, s.dart_score, s.price_score, + COALESCE(s.technical_score, 0) AS technical_score, + s.news_total, s.news_positive, s.news_negative, s.top_reasons, + COALESCE(s.foreign_score, 0) AS foreign_score, + COALESCE(s.short_score, 0) AS short_score, + COALESCE(s.foreign_ratio, 0) AS foreign_ratio, + COALESCE(s.short_weight, 0) AS short_weight + FROM stock_scores s + JOIN dart_corps d ON s.stock_code = d.stock_code AND d.is_active = true + WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores) + ORDER BY s.total_score DESC LIMIT 30 + """) + return [dict(r) for r in rows] + +# ── 추천 종목 ───────────────────────────────────────────────── + +@app.get("/api/recommendations") +async def recommendations(days: int = Query(default=7), limit: int = Query(default=50)): + """매수 추천 — 매매가/포지션/재무/현재가 모두 통합된 종목 카드 응답""" + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT DISTINCT ON (r.stock_code) r.stock_code, r.stock_name, r.recommendation, + r.total_score, r.news_score, r.dart_score, r.price_score, r.technical_score, + r.top_reasons, r.recommended_at + FROM stock_recommendations r + JOIN dart_corps d ON r.stock_code = d.stock_code AND d.is_active = true + WHERE r.recommended_at >= NOW() - INTERVAL '%s days' + AND r.recommendation IN ('강력매수', '매수관심') + ORDER BY r.stock_code, r.total_score DESC, r.recommended_at DESC + """ % days) + rows_sorted = sorted(rows, key=lambda r: r["total_score"], reverse=True)[:limit] + out = [] + for r in rows_sorted: + d = dict(r) + await _enrich_rec(c, redis_cl, d["stock_code"], d) + out.append(d) + return out + +@app.get("/api/avoid") +async def avoid(days: int = Query(default=7), limit: int = Query(default=30)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT DISTINCT ON (r.stock_code) r.stock_code, r.stock_name, r.recommendation, + r.total_score, r.news_score, r.dart_score, r.price_score, r.technical_score, + r.top_reasons, r.recommended_at + FROM stock_recommendations r + JOIN dart_corps d ON r.stock_code = d.stock_code AND d.is_active = true + WHERE r.recommended_at >= NOW() - INTERVAL '%s days' + AND r.recommendation IN ('매도관심', '강력매도') + ORDER BY r.stock_code, r.total_score ASC, r.recommended_at DESC + """ % days) + rows_sorted = sorted(rows, key=lambda r: r["total_score"])[:limit] + out = [] + for r in rows_sorted: + d = dict(r) + await _enrich_rec(c, redis_cl, d["stock_code"], d) + out.append(d) + return out + +# ── 매매 시그널 ────────────────────────────────────────────── + +@app.get("/api/signals") +async def signals(days: int = Query(default=7), limit: int = Query(default=100), + signal_type: str = Query(default="")): + async with pg_pool.acquire() as c: + if signal_type: + rows = await c.fetch(""" + SELECT ts.*, t.tech_score, t.signals AS ta_signals, t.targets + FROM trade_signals ts + JOIN dart_corps d ON ts.stock_code = d.stock_code AND d.is_active = true + LEFT JOIN stock_technical t ON ts.stock_code = t.stock_code + WHERE ts.created_at >= NOW() - INTERVAL '%s days' + AND ts.signal_type = $1 + ORDER BY ts.confidence DESC LIMIT $2 + """ % days, signal_type, limit) + else: + rows = await c.fetch(""" + SELECT ts.*, t.tech_score, t.signals AS ta_signals, t.targets + FROM trade_signals ts + JOIN dart_corps d ON ts.stock_code = d.stock_code AND d.is_active = true + LEFT JOIN stock_technical t ON ts.stock_code = t.stock_code + WHERE ts.created_at >= NOW() - INTERVAL '%s days' + ORDER BY ts.confidence DESC LIMIT $1 + """ % days, limit) + result = [] + for row in rows: + d = dict(row) + d["created_at"] = str(d["created_at"]) + for k in ("ta_signals", "targets"): + if isinstance(d.get(k), str): + try: d[k] = json.loads(d[k]) + except: d[k] = [] + result.append(d) + return result + +# ── 기술적 분석 ────────────────────────────────────────────── + +@app.get("/api/technical/{code}") +async def technical(code: str): + # Redis db=5 (ta-engine) + ta_redis = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True) + try: + cached = await ta_redis.get(f"ta:{code}") + if cached: + return JSONResponse(content=json.loads(cached)) + finally: + await ta_redis.aclose() + + async with pg_pool.acquire() as c: + row = await c.fetchrow("SELECT * FROM stock_technical WHERE stock_code=$1", code) + if row: + d = dict(row) + d["analyzed_at"] = str(d["analyzed_at"]) + for k in ("signals", "targets"): + if isinstance(d.get(k), str): + try: d[k] = json.loads(d[k]) + except: pass + return JSONResponse(content=d) + return JSONResponse(content={"error": "not found"}, status_code=404) + +@app.get("/api/buy-candidates") +async def buy_candidates(limit: int = Query(default=20)): + """기술적+펀더멘탈 통합 매수 후보 (버핏 가치 필터 포함)""" + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT t.stock_code, + COALESCE(NULLIF(t.stock_name,''), d.corp_name, t.stock_code) AS stock_name, + t.price, t.tech_score, t.signal, t.signals, t.targets, + t.rsi, t.macd_hist, t.ma5, t.ma20, t.ma60, + t.bb_upper, t.bb_lower, t.pct_b, t.vol_ratio, + s.news_score, s.dart_score, s.recommendation, + COALESCE(s.total_score, 0) AS score, + (t.tech_score * 0.4 + COALESCE(s.total_score, 0) * 0.6) AS combined_score, + COALESCE(s.foreign_score, 0) AS foreign_score, + COALESCE(s.short_weight, 0) AS short_weight, + f.roe, f.operating_margin, f.debt_ratio, f.revenue_growth, f.net_margin + FROM stock_technical t + JOIN dart_corps d ON t.stock_code = d.stock_code AND d.is_active = true + LEFT JOIN stock_scores s + ON t.stock_code = s.stock_code + AND s.score_date = (SELECT MAX(score_date) FROM stock_scores) + LEFT JOIN LATERAL ( + SELECT roe, operating_margin, debt_ratio, revenue_growth, net_margin, + operating_profit, revenue + FROM dart_financials + WHERE stock_code = t.stock_code + ORDER BY bsns_year DESC, reprt_code DESC + LIMIT 1 + ) f ON true + WHERE t.signal = '매수' AND t.tech_score >= 30 + AND t.price >= 1000 + AND t.stock_name NOT LIKE '%기업인수목적%' + AND t.stock_name NOT LIKE '%선박투자%' + AND t.stock_name NOT LIKE '%부동산투자회사%' + AND t.stock_name NOT LIKE '%특별자산%' + AND (f.stock_code IS NULL OR f.operating_profit > 0) + ORDER BY combined_score DESC + LIMIT $1 + """, limit) + result = [] + for row in rows: + d = dict(row) + for k in ("signals", "targets"): + if isinstance(d.get(k), str): + try: d[k] = json.loads(d[k]) + except: d[k] = [] + result.append(d) + return result + +# ── 알림 ───────────────────────────────────────────────────── + +@app.get("/api/alerts") +async def alerts(): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT title, sentiment, intensity, primary_stock, + reason, investment_action, source, analyzed_at + FROM news_analysis + WHERE intensity >= 3 AND analyzed_at >= NOW() - INTERVAL '24 hours' + ORDER BY intensity DESC, analyzed_at DESC LIMIT 20 + """) + return [dict(r) for r in rows] + +# ── 타임라인 ───────────────────────────────────────────────── + +@app.get("/api/timeline") +async def timeline(hours: int = Query(default=24)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT date_trunc('hour', analyzed_at) AS hour, + SUM(CASE WHEN sentiment='호재' THEN 1 ELSE 0 END) AS pos, + SUM(CASE WHEN sentiment='악재' THEN 1 ELSE 0 END) AS neg, + COUNT(*) AS total + FROM news_analysis + WHERE analyzed_at >= NOW() - INTERVAL '%s hours' + GROUP BY hour ORDER BY hour + """ % hours) + return [{"hour": str(r["hour"]), "positive": r["pos"], + "negative": r["neg"], "total": r["total"]} for r in rows] + +# ── 종목 상세 ───────────────────────────────────────────────── + +@app.get("/api/stock/{code}") +async def stock(code: str): + async with pg_pool.acquire() as c: + news = await c.fetch(""" + SELECT title, sentiment, intensity, reason, source, analyzed_at + FROM news_analysis WHERE primary_stock=$1 + ORDER BY analyzed_at DESC LIMIT 20 + """, code) + scores = await c.fetch( + "SELECT * FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 30", code) + fin = await c.fetch( + "SELECT * FROM dart_financials WHERE stock_code=$1 ORDER BY bsns_year DESC", code) + sigs = await c.fetch( + "SELECT * FROM trade_signals WHERE stock_code=$1 ORDER BY created_at DESC LIMIT 5", code) + price = None + ta = None + foreign = None + short = None + ohlcv = None + if redis_cl: + try: + p = await redis_cl.get(f"price:{code}") + if p: price = json.loads(p) + except: pass + try: + f = await redis_cl.get(f"foreign:{code}") + if f: foreign = json.loads(f)[:10] + except: pass + try: + s = await redis_cl.get(f"short:{code}") + if s: short = json.loads(s)[:10] + except: pass + try: + o = await redis_cl.get(f"ohlcv:{code}") + if o: ohlcv = json.loads(o)[:30] + except: pass + ta_redis = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True) + try: + t = await ta_redis.get(f"ta:{code}") + if t: ta = json.loads(t) + except: pass + finally: + await ta_redis.aclose() + + def _serial(rows): + result = [] + for row in rows: + d = dict(row) + for k, v in d.items(): + if hasattr(v, 'isoformat'): d[k] = str(v) + result.append(d) + return result + + return { + "code": code, "price": price, "technical": ta, + "news": _serial(news), "scores": _serial(scores), + "financials": _serial(fin), "signals": _serial(sigs), + "foreign": foreign, "short": short, "ohlcv": ohlcv, + } + +# ── 검색 ────────────────────────────────────────────────────── + +@app.get("/api/search") +async def search(q: str = Query(default=""), days: int = Query(default=30)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT n.title, n.sentiment, n.intensity, n.primary_stock, + n.reason, n.investment_action, n.source, n.analyzed_at, + s.total_score, s.recommendation, + s.news_score, s.dart_score, s.price_score, + COALESCE(s.technical_score, 0) AS technical_score + FROM news_analysis n + LEFT JOIN stock_scores s + ON n.primary_stock = s.stock_code + AND s.score_date = (SELECT MAX(score_date) FROM stock_scores) + WHERE (n.primary_stock ILIKE $1 OR n.title ILIKE $1) + AND n.analyzed_at >= NOW() - INTERVAL '%s days' + ORDER BY n.analyzed_at DESC LIMIT 50 + """ % days, f"%{q}%") + return [dict(r) for r in rows] + +# ── 포지션 분석 (ta-engine 프록시) ─────────────────────────── + +@app.post("/api/position") +async def position(req: PositionReq, ai: bool = False): + """보유 종목 매입가/수량 입력 → 손익 + 맞춤 전략 반환""" + async with httpx.AsyncClient() as c: + r = await c.post( + f"{TA_ENGINE_URL}/position?ai={str(ai).lower()}", + json=req.dict(), timeout=90) + return JSONResponse(content=r.json()) + +@app.get("/api/report/{code}") +async def report(code: str): + """종목 전체 리포트 (기술적 분석 + AI 판단문 + 뉴스)""" + async with httpx.AsyncClient() as c: + r = await c.get(f"{TA_ENGINE_URL}/report/{code}", timeout=90) + return JSONResponse(content=r.json()) + +# ── 펀더멘털 분석 (버핏 가치투자 뷰) ──────────────────────── + +@app.get("/api/fundamentals") +async def fundamentals(limit: int = Query(default=30)): + """버핏 가치 기준 상위 종목 - ROE, 영업이익률, 부채비율 기반""" + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT DISTINCT ON (f.stock_code) + f.stock_code, d.corp_name AS stock_name, + f.bsns_year, f.reprt_name, + f.roe, f.operating_margin, f.net_margin, + f.debt_ratio, f.revenue_growth, f.fcf_ratio, + f.revenue, f.operating_profit, f.net_income, + f.total_equity, f.total_liabilities, + s.total_score, s.recommendation + FROM dart_financials f + JOIN dart_corps d ON f.stock_code = d.stock_code AND d.is_active = true + LEFT JOIN stock_scores s + ON f.stock_code = s.stock_code + AND s.score_date = (SELECT MAX(score_date) FROM stock_scores) + WHERE f.operating_profit > 0 + AND f.revenue > 0 + AND f.debt_ratio BETWEEN 0 AND 80 + AND f.roe > 5 + ORDER BY f.stock_code, f.bsns_year DESC, f.reprt_code DESC + LIMIT $1 + """, limit * 3) + # ROE 기준 재정렬 + sorted_rows = sorted(rows, key=lambda r: (r["roe"] or 0), reverse=True) + return [dict(r) for r in sorted_rows[:limit]] + + +@app.get("/api/fundamentals/{code}") +async def fundamentals_detail(code: str): + """특정 종목 재무 이력""" + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT bsns_year, reprt_name, revenue, operating_profit, net_income, + total_assets, total_liabilities, total_equity, operating_cashflow, + roe, operating_margin, net_margin, debt_ratio, revenue_growth, fcf_ratio + FROM dart_financials + WHERE stock_code = $1 + ORDER BY bsns_year DESC, reprt_code DESC + LIMIT 8 + """, code) + return [dict(r) for r in rows] + +# ── 종목명 조회 ────────────────────────────────────────────── + +@app.get("/api/name/{code}") +async def stock_name(code: str): + async with pg_pool.acquire() as c: + name = await c.fetchval("SELECT corp_name FROM dart_corps WHERE stock_code=$1", code) + if not name: + name = await c.fetchval( + "SELECT stock_name FROM stock_scores WHERE stock_code=$1 LIMIT 1", code) + return {"code": code, "name": name or ""} + +@app.get("/api/performance") +async def performance(): + """추천 성과 통계 (score-engine DB 직접 조회)""" + async with pg_pool.acquire() as c: + stats = await c.fetchrow(""" + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE return_7d IS NOT NULL) AS measured_7d, + ROUND(AVG(return_7d) FILTER (WHERE return_7d IS NOT NULL)::numeric, 2) AS avg_return_7d, + COUNT(*) FILTER (WHERE return_7d > 0) AS wins_7d, + ROUND(AVG(return_30d) FILTER (WHERE return_30d IS NOT NULL)::numeric, 2) AS avg_return_30d, + COUNT(*) FILTER (WHERE return_30d > 0) AS wins_30d, + COUNT(*) FILTER (WHERE return_30d IS NOT NULL) AS measured_30d, + ROUND(AVG(return_7d) FILTER (WHERE recommendation='강력매수' AND return_7d IS NOT NULL)::numeric, 2) AS strong_buy_avg_7d + FROM recommendation_performance + WHERE rec_date >= CURRENT_DATE - 90 + """) + recent = await c.fetch(""" + SELECT stock_code, stock_name, recommendation, entry_price, + price_7d, return_7d, price_30d, return_30d, rec_date + FROM recommendation_performance + WHERE return_7d IS NOT NULL OR return_30d IS NOT NULL + ORDER BY rec_date DESC LIMIT 30 + """) + def s(r): return {**dict(r), "rec_date": str(r["rec_date"])} + return {"summary": dict(stats) if stats else {}, "recent": [s(r) for r in recent]} + +@app.get("/api/sector-ranking") +async def sector_ranking(): + """섹터별 평균 AI 스코어 랭킹""" + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT + COALESCE(NULLIF(d.sector_name,''), '기타') AS sector, + COUNT(DISTINCT s.stock_code) AS stock_count, + ROUND(AVG(s.total_score)::numeric, 1) AS avg_score, + ROUND(AVG(s.news_score)::numeric, 1) AS avg_news, + ROUND(AVG(s.technical_score)::numeric, 1) AS avg_tech, + COUNT(*) FILTER (WHERE s.recommendation IN ('강력매수','매수관심')) AS buy_count, + COUNT(*) FILTER (WHERE s.recommendation IN ('강력매도','매도관심')) AS sell_count + FROM stock_scores s + JOIN dart_corps d ON s.stock_code = d.stock_code AND d.is_active = true + WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores) + GROUP BY d.sector_name + HAVING COUNT(DISTINCT s.stock_code) >= 3 + ORDER BY AVG(s.total_score) DESC + """) + return [dict(r) for r in rows] + +@app.get("/api/portfolio/prices") +async def portfolio_prices(codes: str = Query(default="")): + """보유 종목 현재가 + 기술점수 + AI점수 일괄 조회""" + code_list = [c.strip() for c in codes.split(",") if c.strip() and len(c.strip()) == 6] + if not code_list: + return [] + ta_redis = aioredis.Redis(host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True) + ta_map = {} + try: + vals = await ta_redis.mget(*[f"ta:{c}" for c in code_list]) + for code, v in zip(code_list, vals): + if v: + ta_map[code] = json.loads(v) + except: pass + finally: + await ta_redis.aclose() + price_map = {} + if redis_cl: + try: + vals = await redis_cl.mget(*[f"price:{c}" for c in code_list]) + for code, v in zip(code_list, vals): + if v: + price_map[code] = json.loads(v) + except: pass + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT stock_code, total_score, recommendation, news_score, technical_score + FROM stock_scores + WHERE stock_code = ANY($1) + AND score_date = (SELECT MAX(score_date) FROM stock_scores) + """, code_list) + score_map = {r["stock_code"]: dict(r) for r in rows} + result = [] + for code in code_list: + ta = ta_map.get(code, {}) + pr = price_map.get(code, {}) + sc = score_map.get(code, {}) + price = pr.get("price") or ta.get("price") or 0 + result.append({ + "code": code, + "price": int(price) if price else 0, + "change_pct": float(pr.get("change_pct") or 0), + "tech_score": float(ta.get("tech_score") or 0), + "signal": ta.get("signal") or "관망", + "ai_score": sc.get("total_score"), + "recommendation": sc.get("recommendation"), + }) + return result + +# ── 외국인·공매도·OHLCV ─────────────────────────────────────── + +@app.get("/api/foreign/{code}") +async def foreign_flow(code: str): + """외국인 수급 데이터 (Redis + DB 폴백)""" + if redis_cl: + c = await redis_cl.get(f"foreign:{code}") + if c: + return JSONResponse(content={"code": code, "data": json.loads(c)}) + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT dt, close_price, change_qty, hold_qty, hold_ratio, limit_ratio + FROM stock_foreign_flow WHERE stock_code=$1 + ORDER BY dt DESC LIMIT 30 + """, code) + result = [] + for row in rows: + d = dict(row) + d["dt"] = str(d["dt"]).replace("-", "") + result.append(d) + return JSONResponse(content={"code": code, "data": result}) + + +@app.get("/api/short/{code}") +async def short_sale(code: str): + """공매도 데이터 (Redis + DB 폴백)""" + if redis_cl: + c = await redis_cl.get(f"short:{code}") + if c: + return JSONResponse(content={"code": code, "data": json.loads(c)}) + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT dt, close_price, short_qty, short_balance_qty, trade_weight, short_avg_price + FROM stock_short_sale WHERE stock_code=$1 + ORDER BY dt DESC LIMIT 30 + """, code) + result = [] + for row in rows: + d = dict(row) + d["dt"] = str(d["dt"]).replace("-", "") + result.append(d) + return JSONResponse(content={"code": code, "data": result}) + + +@app.get("/api/ohlcv/{code}") +async def ohlcv(code: str, days: int = Query(default=60)): + """일봉 OHLCV + 외국인·기관 순매수 (Redis + DB 폴백)""" + if redis_cl: + c = await redis_cl.get(f"ohlcv:{code}") + if c: + data = json.loads(c) + return JSONResponse(content={"code": code, "data": data[:days]}) + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT dt, open_price, high_price, low_price, close_price, + volume, trade_amount, foreign_ratio, foreign_net, institution_net + FROM stock_ohlcv WHERE stock_code=$1 + ORDER BY dt DESC LIMIT $2 + """, code, days) + result = [] + for row in rows: + d = dict(row) + d["dt"] = str(d["dt"]).replace("-", "") + result.append(d) + return JSONResponse(content={"code": code, "data": result}) + + +@app.get("/api/supply-demand") +async def supply_demand(limit: int = Query(default=20)): + """외국인 순매수 상위 종목 (최근 5일 누적)""" + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT ff.stock_code, + COALESCE(d.corp_name, ff.stock_code) AS stock_name, + SUM(ff.change_qty) AS net_5d, + AVG(ff.hold_ratio) AS avg_ratio, + MAX(ff.hold_ratio) - MIN(ff.hold_ratio) AS ratio_delta, + MAX(ff.close_price) AS price + FROM stock_foreign_flow ff + JOIN dart_corps d ON ff.stock_code = d.stock_code AND d.is_active = true + WHERE ff.dt >= CURRENT_DATE - 5 + GROUP BY ff.stock_code, d.corp_name + HAVING SUM(ff.change_qty) != 0 + ORDER BY SUM(ff.change_qty) DESC + LIMIT $1 + """, limit) + return [dict(r) for r in rows] + + +@app.get("/api/short-ranking") +async def short_ranking(limit: int = Query(default=20)): + """공매도 비중 상위 종목 (최근일 기준)""" + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT DISTINCT ON (ss.stock_code) + ss.stock_code, + COALESCE(d.corp_name, ss.stock_code) AS stock_name, + ss.trade_weight, ss.short_qty, ss.short_balance_qty, + ss.short_avg_price, ss.close_price, ss.dt + FROM stock_short_sale ss + JOIN dart_corps d ON ss.stock_code = d.stock_code AND d.is_active = true + WHERE ss.dt >= CURRENT_DATE - 3 + ORDER BY ss.stock_code, ss.dt DESC + """) + sorted_rows = sorted(rows, key=lambda r: r["trade_weight"] or 0, reverse=True) + return [dict(r) for r in sorted_rows[:limit]] + + +# ── 챗봇 ───────────────────────────────────────────────────── + +class ChatReq(BaseModel): + message: str + history: list = [] + +async def _build_context() -> str: + try: + async with pg_pool.acquire() as c: + # 상위 매수 추천 + 기술적 데이터 합산 + recs = await c.fetch(""" + SELECT s.stock_code, d.corp_name, s.total_score, s.recommendation, + s.news_score, s.technical_score, s.dart_score, + s.foreign_score, s.short_score, + t.price, t.rsi, t.tech_score AS ta_score, t.signal AS ta_signal, + t.vol_ratio, t.ma5, t.ma20, t.ma60, + COALESCE(t.signals, '[]') AS ta_signals + FROM stock_scores s + JOIN dart_corps d ON d.stock_code = s.stock_code AND d.is_active = true + LEFT JOIN stock_technical t ON t.stock_code = s.stock_code + WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores) + AND s.total_score >= 30 + ORDER BY s.total_score DESC LIMIT 15 + """) + # 매도 주의 종목 + sells = await c.fetch(""" + SELECT s.stock_code, d.corp_name, s.total_score, s.recommendation, + t.price, t.rsi, t.signal AS ta_signal + FROM stock_scores s + JOIN dart_corps d ON d.stock_code = s.stock_code AND d.is_active = true + LEFT JOIN stock_technical t ON t.stock_code = s.stock_code + WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores) + AND s.total_score <= -30 + ORDER BY s.total_score ASC LIMIT 5 + """) + # 오늘 매매시그널 + sigs = await c.fetch(""" + SELECT ts.stock_code, ts.stock_name, ts.signal_type, + ts.current_price, ts.target_price, ts.stop_loss, + ts.confidence, ts.expected_return_pct, ts.reason, + ts.news_score, ts.dart_score, ts.price_momentum, + ts.foreign_net_5d, ts.short_weight + FROM trade_signals ts + WHERE ts.created_at::date = CURRENT_DATE + ORDER BY ts.confidence DESC LIMIT 10 + """) + # 최근 24시간 주요 뉴스 + news = await c.fetch(""" + SELECT title, sentiment, intensity, primary_stock, + COALESCE(stock_names::text,'[]') AS stock_names, reason, catalyst + FROM news_analysis + WHERE analyzed_at >= NOW() - INTERVAL '24 hours' + AND intensity >= 3 + ORDER BY intensity DESC, analyzed_at DESC LIMIT 10 + """) + # 시장 전체 통계 + stats = await c.fetchrow(""" + SELECT + COUNT(*) FILTER (WHERE total_score>=60) AS strong_buy, + COUNT(*) FILTER (WHERE total_score>=30 AND total_score<60) AS buy, + COUNT(*) FILTER (WHERE total_score<=-30) AS sell, + ROUND(AVG(total_score)::numeric,1) AS avg_score, + MAX(score_date) AS score_date + FROM stock_scores s JOIN dart_corps d ON d.stock_code=s.stock_code + WHERE d.is_active=true + AND s.score_date=(SELECT MAX(score_date) FROM stock_scores) + """) + today = stats['score_date'].strftime('%Y-%m-%d') if stats['score_date'] else '오늘' + ctx = f"=== 시장 개요 ({today}) ===\n" + ctx += f"강력매수: {stats['strong_buy']}종목 / 매수관심: {stats['buy']}종목 / 매도관심: {stats['sell']}종목 / 평균점수: {stats['avg_score']}\n\n" + + ctx += "=== 매수 추천 종목 (점수·가격·기술지표) ===\n" + for r in recs: + price_str = f"{r['price']:,}원" if r['price'] else "가격미수집" + rsi_str = f"RSI{r['rsi']:.0f}" if r['rsi'] else "" + ma_str = "" + if r['ma5'] and r['ma20']: + ma_str = "▲정배열" if r['ma5'] > r['ma20'] else "▽역배열" + vol_str = f"거래량{r['vol_ratio']:.1f}x" if r['vol_ratio'] else "" + sigs_list = json.loads(r['ta_signals']) if r['ta_signals'] else [] + sigs_str = " | ".join(sigs_list[:3]) if sigs_list else "" + ctx += (f"- **{r['corp_name']}({r['stock_code']})** {price_str} " + f"종합{r['total_score']:.0f}점[{r['recommendation']}] " + f"뉴스{r['news_score']:.0f}/기술{r['technical_score']:.0f}/공시{r['dart_score']:.0f} " + f"{rsi_str} {ma_str} {vol_str}\n") + if sigs_str: + ctx += f" 기술신호: {sigs_str}\n" + + if sells: + ctx += "\n=== 주의/회피 종목 ===\n" + for r in sells: + price_str = f"{r['price']:,}원" if r['price'] else "가격미수집" + rsi_str = f"RSI{r['rsi']:.0f}" if r['rsi'] else "RSI:N/A" + ctx += f"- {r['corp_name']}({r['stock_code']}) {price_str} {r['total_score']:.0f}점[{r['recommendation']}] {rsi_str}\n" + + if sigs: + ctx += "\n=== 오늘 매매시그널 ===\n" + for s in sigs: + ret_str = f"기대수익{s['expected_return_pct']:.1f}%" if s['expected_return_pct'] else "" + ctx += (f"- **{s['stock_name']}({s['stock_code']})** {s['signal_type']} " + f"현재가{s['current_price']:,}원 → 목표{s['target_price']:,}원 " + f"손절{s['stop_loss']:,}원 신뢰도{s['confidence']:.0f}% {ret_str}\n" + f" 근거: {(s['reason'] or '')[:100]}\n") + + if news: + ctx += "\n=== 최근 24시간 주요 뉴스 ===\n" + for n in news: + names = n['stock_names'] if n['stock_names'] != '[]' else '' + ctx += f"- [{n['sentiment']}·강도{n['intensity']}·{n['catalyst']}] {n['title'][:70]}\n" + ctx += f" → {(n['reason'] or '')[:90]}\n" + + return ctx + except Exception as e: + return f"(컨텍스트 로드 실패: {e})" + +async def _calc_valuation(code: str, eps: float, sector: str, current_price: int) -> dict: + """ + 종목별 적정가 추정 (3가지 방식 통합) + A. DCF 내재가치 — stock_scores.intrinsic_value (5년 영업현금흐름 + Gordon 영구가치) + B. 섹터 평균 PER × EPS + C. 시나리오: 보수(P25) / 중립(P50) / 낙관(P75) PER × EPS + """ + out: dict = {} + try: + async with pg_pool.acquire() as c: + # A. DCF + sc = await c.fetchrow(""" + SELECT intrinsic_value, margin_of_safety FROM stock_scores + WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1 + """, code) + if sc and sc['intrinsic_value']: + iv = int(sc['intrinsic_value']) + if iv > 0 and current_price > 0: + # 시총 → 주당가치 환산: intrinsic_value는 기업가치(원) → 발행주식수 필요 + # stock_scores.intrinsic_value는 시총 단위로 저장되어 있어 시총/현재가 비율로 적정 주가 환산 + async with pg_pool.acquire() as c2: + mc_row = await c2.fetchrow(""" + SELECT market_cap FROM stock_prices WHERE stock_code=$1 + ORDER BY collected_at DESC LIMIT 1 + """, code) + if mc_row and mc_row['market_cap'] and mc_row['market_cap'] > 0: + ratio = iv / float(mc_row['market_cap']) + out['dcf_fair_price'] = int(current_price * ratio) + out['dcf_safety_pct'] = round((out['dcf_fair_price'] - current_price) + / current_price * 100, 1) + # B + C. 섹터 PER 분포 + if sector and sector != '기타' and eps and eps > 0: + rows = await c.fetch(""" + SELECT p.per FROM stock_prices p + JOIN dart_corps d ON d.stock_code=p.stock_code + WHERE d.sector=$1 AND p.per > 0 AND p.per < 100 + AND p.collected_at >= NOW()-INTERVAL '7 days' + """, sector) + pers = sorted(float(r['per']) for r in rows) + if len(pers) >= 5: + p25, p50, p75 = pers[len(pers)//4], pers[len(pers)//2], pers[3*len(pers)//4] + out['sector_per_p25'] = round(p25, 1) + out['sector_per_p50'] = round(p50, 1) + out['sector_per_p75'] = round(p75, 1) + out['sector_n'] = len(pers) + out['fair_p25'] = int(p25 * eps) + out['fair_p50'] = int(p50 * eps) + out['fair_p75'] = int(p75 * eps) + if current_price > 0: + out['upside_p50'] = round((p50*eps - current_price)/current_price*100, 1) + except Exception as e: + out['err'] = str(e) + return out + + +async def _fetch_naver_live_price(code: str) -> dict: + """네이버 모바일 종목 API에서 실시간 가격·PER·PBR·시총·배당 즉시 fetch""" + try: + async with httpx.AsyncClient(timeout=8) as client: + r = await client.get( + f"https://m.stock.naver.com/api/stock/{code}/integration", + headers={"User-Agent": "Mozilla/5.0"}) + if r.status_code != 200: + return {} + d = r.json() + info = {ti.get("key"): ti.get("value") for ti in (d.get("totalInfos") or [])} + # basic API에서 closePrice / fluctuationsRatio 별도 fetch + r2 = await client.get( + f"https://m.stock.naver.com/api/stock/{code}/basic", + headers={"User-Agent": "Mozilla/5.0"}) + basic = r2.json() if r2.status_code == 200 else {} + return { + "name": d.get("stockName") or basic.get("stockName") or "", + "close": basic.get("closePrice") or info.get("전일") or "", + "change_pct": basic.get("fluctuationsRatio") or 0, + "compare": basic.get("compareToPreviousClosePrice") or 0, + "open": info.get("시가"), "high": info.get("고가"), "low": info.get("저가"), + "volume": info.get("거래량"), "market_cap": info.get("시총"), + "foreign_ratio": info.get("외인소진율"), + "high_52w": info.get("52주 최고"), "low_52w": info.get("52주 최저"), + "per": info.get("PER"), "eps": info.get("EPS"), + "per_est": info.get("추정PER"), "eps_est": info.get("추정EPS"), + "pbr": info.get("PBR"), "bps": info.get("BPS"), + "div_yield": info.get("배당수익률"), "dps": info.get("주당배당금"), + } + except Exception: + return {} + + +async def _get_stock_detail(code: str) -> str: + """특정 종목 상세 데이터 조회 (Redis 실시간 → DB → 네이버 API 폴백)""" + try: + # Redis db=3에서 실시간 가격 우선 조회 + redis_price = None + price_fresh = False + try: + p = await redis_cl.get(f"price:{code}") + if p: + redis_price = json.loads(p) + price_fresh = True + except: + pass + + # Redis에 없으면 네이버 모바일 API에서 즉시 fetch (일요일/장외 시간에도 종가 제공) + naver_live = {} + if not redis_price: + naver_live = await _fetch_naver_live_price(code) + + async with pg_pool.acquire() as c: + tech = await c.fetchrow( + "SELECT * FROM stock_technical WHERE stock_code=$1", code) + score = await c.fetchrow(""" + SELECT s.*, d.corp_name FROM stock_scores s + JOIN dart_corps d ON d.stock_code=s.stock_code + WHERE s.stock_code=$1 + ORDER BY s.score_date DESC LIMIT 1 + """, code) + fin = await c.fetchrow(""" + SELECT * FROM dart_financials WHERE stock_code=$1 + ORDER BY bsns_year DESC, reprt_code DESC LIMIT 1 + """, code) + recent_news = await c.fetch(""" + SELECT title, sentiment, intensity, reason, analyzed_at + FROM news_analysis + WHERE primary_stock=$1 OR stock_codes::text LIKE $2 + ORDER BY analyzed_at DESC LIMIT 5 + """, code, f'%{code}%') + sig = await c.fetchrow( + "SELECT * FROM trade_signals WHERE stock_code=$1 AND created_at::date=CURRENT_DATE", code) + + parts = [] + name = score['corp_name'] if score else code + + # 현재가 결정: Redis 실시간 > stock_technical (스냅샷) + current_price = None + price_note = "" + if redis_price and redis_price.get("price"): + current_price = int(redis_price["price"]) + chg = redis_price.get("change_pct", 0) + price_note = f"(실시간) 등락:{chg:+.2f}%" + elif naver_live.get("close"): + try: + current_price = int(str(naver_live["close"]).replace(",", "")) + chg = float(naver_live.get("change_pct") or 0) + price_note = f"(네이버 종가) 등락:{chg:+.2f}%" + except: pass + elif tech and tech.get("price"): + current_price = int(tech["price"]) + analyzed = tech["analyzed_at"] + if analyzed: + age = datetime.now().replace(tzinfo=None) - analyzed.replace(tzinfo=None) + hours = int(age.total_seconds() / 3600) + price_note = f"(스냅샷, {hours}시간 전 데이터)" + else: + price_note = "(스냅샷)" + + # 네이버 라이브 시세 정보 명시적으로 LLM에게 전달 (PER/PBR/시총 등) + if naver_live.get("close"): + parts.append( + f"[{name} 시세 (네이버 종가 기준, {datetime.now().strftime('%Y-%m-%d %H:%M')})]\n" + f"종가:{naver_live.get('close')}원 등락:{naver_live.get('change_pct',0)}% " + f"(전일대비 {naver_live.get('compare')}원)\n" + f"시가:{naver_live.get('open')} 고가:{naver_live.get('high')} 저가:{naver_live.get('low')} " + f"거래량:{naver_live.get('volume')}\n" + f"시총:{naver_live.get('market_cap')} 외인:{naver_live.get('foreign_ratio')}\n" + f"52주 최고/최저:{naver_live.get('high_52w')} / {naver_live.get('low_52w')}\n" + f"PER:{naver_live.get('per')} (추정 {naver_live.get('per_est')}) | " + f"PBR:{naver_live.get('pbr')} | 배당수익률:{naver_live.get('div_yield')} " + f"(주당배당금 {naver_live.get('dps')})\n" + f"EPS:{naver_live.get('eps')} (추정 {naver_live.get('eps_est')}) | BPS:{naver_live.get('bps')}\n" + ) + + # 적정가 분석 (DCF + 섹터 PER + 시나리오) + try: + eps_val = float(str(naver_live.get('eps') or '0').replace(',', '').replace('원', '')) + sector = (score['sector'] if score and 'sector' in score.keys() else '') or '' + if not sector: + async with pg_pool.acquire() as c: + sec_row = await c.fetchrow("SELECT sector FROM dart_corps WHERE stock_code=$1", code) + sector = (sec_row['sector'] if sec_row else '') or '기타' + val = await _calc_valuation(code, eps_val, sector, current_price or 0) + if val: + lines = [f"[{name} 적정가 분석]"] + if val.get('dcf_fair_price'): + sign = "+" if val['dcf_safety_pct'] > 0 else "" + lines.append(f"DCF 내재가치: {val['dcf_fair_price']:,}원 (안전마진 {sign}{val['dcf_safety_pct']}%)") + if val.get('fair_p50'): + lines.append(f"섹터 PER 기반 적정가 ({sector}, n={val['sector_n']}):") + lines.append(f" 보수(P25, PER {val['sector_per_p25']}배): {val['fair_p25']:,}원") + lines.append(f" 중립(P50, PER {val['sector_per_p50']}배): {val['fair_p50']:,}원 " + f"(상승여력 {val.get('upside_p50',0):+.1f}%)") + lines.append(f" 낙관(P75, PER {val['sector_per_p75']}배): {val['fair_p75']:,}원") + if len(lines) > 1: + parts.append("\n".join(lines) + "\n") + except Exception: + pass + + if tech: + signals = json.loads(tech['signals']) if tech['signals'] else [] + targets = json.loads(tech['targets']) if tech['targets'] else {} + display_price = current_price or tech['price'] + parts.append( + f"[{name} 기술적 분석]\n" + f"현재가: {display_price:,}원 {price_note} | RSI: {tech['rsi']:.1f} | 기술점수: {tech['tech_score']:.0f}\n" + f"MA5:{tech['ma5']:,} MA20:{tech['ma20']:,} MA60:{tech['ma60']:,}\n" + f"볼밴상단:{tech['bb_upper']:,} 볼밴하단:{tech['bb_lower']:,} 볼밴위치:%B{tech['pct_b']:.2f}\n" + f"MACD:{tech['macd']:.1f} 시그널:{tech['macd_signal']:.1f} 히스토그램:{tech['macd_hist']:.1f}\n" + f"거래량비율:{tech['vol_ratio']:.2f}x | 스토캐스틱K:{tech['stoch_k']:.1f} D:{tech['stoch_d']:.1f}\n" + f"기술신호: {' | '.join(signals)}\n" + + (f"목표가 T1:{targets.get('t1',0):,}원({(targets['t1']/display_price-1)*100:.1f}%) " + f"T2:{targets.get('t2',0):,}원({(targets['t2']/display_price-1)*100:.1f}%) " + f"T3:{targets.get('t3',0):,}원({(targets['t3']/display_price-1)*100:.1f}%) " + f"손절:{targets.get('stop_loss',0):,}원({(targets['stop_loss']/display_price-1)*100:.1f}%)\n" + if targets and display_price else "") + ) + # 매매 금액 가이드 + if current_price and targets: + sl = targets.get('stop_loss', 0) + t1 = targets.get('t1', 0) + if sl and sl < current_price: + risk_per_share = current_price - sl + # 투자금 100만원 기준, 리스크 2% = 20,000원 허용 + budget_100w = 1_000_000 + max_risk = budget_100w * 0.02 + safe_shares = int(max_risk / risk_per_share) if risk_per_share > 0 else 0 + safe_amount = safe_shares * current_price + parts.append( + f"[매매 금액 가이드 (100만원 기준, 리스크2% 원칙)]\n" + f"추천매수가: {current_price:,}원 | 손절가: {sl:,}원 (하락여지 {(sl/current_price-1)*100:.1f}%)\n" + f"안전매수 수량: {safe_shares}주 (약 {safe_amount:,}원)\n" + f"T1 도달시 수익: +{safe_shares*(t1-current_price):,}원 ({(t1/current_price-1)*100:.1f}%)\n" + f"※ 실제 투자금에 비례해 수량 조정. 1종목 최대 10~15% 권장\n" + ) + elif current_price: + parts.append(f"[{name}] 현재가: {current_price:,}원 {price_note}\n") + + if score: + parts.append( + f"[{name} 종합점수]\n" + f"총점: {score['total_score']:.1f} [{score['recommendation']}]\n" + f"뉴스:{score['news_score']:.0f} | 기술:{score['technical_score']:.0f} | 공시:{score['dart_score']:.0f} | 외국인:{score['foreign_score']:.0f} | 공매도:{score['short_score']:.0f}\n" + f"외국인보유:{score['foreign_ratio']:.2f}% | 공매도비중:{score['short_weight']:.2f}%\n" + f"호재:{score['news_positive']} 악재:{score['news_negative']} 중립:{score['news_neutral']}\n" + + (f"주요근거: {score['top_reasons'][:150]}\n" if score['top_reasons'] else "") + ) + + if fin: + parts.append( + f"[{name} 재무({fin['bsns_year']}년)]\n" + f"매출:{fin['revenue']:,}억 | 영업이익:{fin['operating_profit']:,}억 | 순이익:{fin['net_income']:,}억\n" + f"ROE:{fin['roe']:.1f}% | 영업이익률:{fin['operating_margin']:.1f}% | 부채비율:{fin['debt_ratio']:.1f}%\n" + f"FCF비율:{fin['fcf_ratio']:.1f}% | 매출성장:{fin['revenue_growth']:.1f}%\n" + ) + + if sig: + parts.append( + f"[오늘 매매시그널] {sig['signal_type']} | 신뢰도:{sig['confidence']:.0f}%\n" + f"진입가:{sig['current_price']:,}원 → T1목표:{sig['target_price']:,}원 손절:{sig['stop_loss']:,}원\n" + f"기대수익:{sig['expected_return_pct']:.1f}% | 외국인5일순매수:{sig['foreign_net_5d']:,}주\n" + f"근거: {(sig['reason'] or '')[:120]}\n" + ) + + if recent_news: + news_text = "\n".join( + f" [{n['sentiment']}·강도{n['intensity']}] {n['title'][:60]} → {(n['reason'] or '')[:70]}" + for n in recent_news + ) + parts.append(f"[최근 뉴스]\n{news_text}\n") + + return "\n".join(parts) if parts else f"{code} 데이터 없음" + except Exception as e: + return f"({code} 조회 실패: {e})" + +async def _detect_stocks(message: str) -> list[str]: + """메시지에서 종목명/코드 감지 → 코드 목록 반환 + 우선순위: + 1. 한글 종목명 양방향 LIKE 매칭 (HD현대중공업 ↔ 현대중공업 등) + 2. 6자리 종목코드 직접 매칭 + 종목명 우선 — 사용자가 잘못된 코드 적어도 종목명으로 정정""" + try: + found: list[str] = [] + # 1. 한글 단어(2자 이상) 추출 후 LIKE 검색 + words = [w for w in re.findall(r'[가-힣A-Za-z]{2,}', message) if len(w) >= 2] + if words: + async with pg_pool.acquire() as c: + for word in words: + if len(found) >= 3: break + rows = await c.fetch(""" + SELECT stock_code, corp_name FROM dart_corps + WHERE is_active=true + AND (corp_name LIKE $1 OR corp_name LIKE $2) + ORDER BY LENGTH(corp_name) ASC LIMIT 3 + """, f'%{word}%', f'%{word.replace(" ", "")}%') + for r in rows: + if r['stock_code'] not in found: + found.append(r['stock_code']) + if len(found) >= 3: break + # 2. 6자리 코드 매칭 (종목명 매칭이 없을 때만) + if not found: + codes = re.findall(r'\b(\d{6})\b', message) + if codes: + async with pg_pool.acquire() as c: + rows = await c.fetch( + "SELECT stock_code FROM dart_corps WHERE stock_code = ANY($1) AND is_active=true", + codes) + found = [r['stock_code'] for r in rows][:3] + return found + except Exception: + return [] + +@app.post("/api/chat") +async def chat(req: ChatReq): + context, stock_codes = await asyncio.gather( + _build_context(), + _detect_stocks(req.message) + ) + stock_ctx = "" + if stock_codes: + details = await asyncio.gather(*[_get_stock_detail(c) for c in stock_codes]) + stock_ctx = "\n\n=== 질문 관련 종목 상세 데이터 ===\n" + "\n".join(details) + + system = ( + "당신은 워렌 버핏 스타일의 한국 주식 투자 전문 AI 애널리스트입니다.\n" + "가치투자 관점(ROE·영업이익률·부채비율·FCF)을 최우선으로 판단합니다.\n" + "아래 실시간 시스템 데이터를 기반으로 구체적인 수치를 인용하며 답변하세요.\n" + "답변은 핵심 위주로, 한국어로 작성하세요. 마크다운 사용 가능.\n\n" + "【중요】 특정 종목 가격·투자 여부 질문 시 반드시:\n" + "1. 현재가(실시간 또는 스냅샷 여부 명시)\n" + "2. 진입가(추천 매수가) / T1·T2·T3 목표가 / 손절가 (%, 원 단위)\n" + "3. 100만원 투자 기준 매수 수량과 리스크 금액 예시\n" + "4. 데이터가 스냅샷이면 '주가는 장중 변동이 있을 수 있음' 명시\n" + "가격 데이터가 없으면 '현재 가격 데이터를 조회할 수 없습니다' 라고 명시할 것.\n" + "절대 임의의 가격을 만들어내지 말 것.\n\n" + f"{context}{stock_ctx}" + ) + messages = [{"role": "system", "content": system}] + for h in req.history[-10:]: + messages.append(h) + messages.append({"role": "user", "content": req.message}) + + async def stream(): + async with httpx.AsyncClient(timeout=120) as client: + async with client.stream("POST", f"{OLLAMA_URL}/v1/chat/completions", json={ + "model": CHAT_MODEL, "messages": messages, + "stream": True, "temperature": 0.3, "max_tokens": 1024 + }) as resp: + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + yield "data: [DONE]\n\n" + break + try: + delta = json.loads(payload)["choices"][0]["delta"].get("content", "") + if delta: + yield f"data: {json.dumps({'content': delta}, ensure_ascii=False)}\n\n" + except: + pass + + return StreamingResponse(stream(), media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +# ── KOSPI 지수 ─────────────────────────────────────────────── + +@app.get("/api/kospi") +async def kospi(days: int = Query(default=30)): + """네이버 금융에서 KOSPI 최근 N일 데이터 조회""" + try: + async with httpx.AsyncClient(timeout=10) as c: + today = datetime.now().strftime("%Y%m%d") + start = (datetime.now() - timedelta(days=days + 10)).strftime("%Y%m%d") + r = await c.get( + "https://m.stock.naver.com/api/index/KOSPI/price", + params={"startTime": start, "endTime": today, "timeframe": "day"}, + headers={"User-Agent": "Mozilla/5.0"} + ) + data = r.json() + # 최근 N일만 + def nf(v): return float(str(v).replace(",", "") or 0) + items = data if isinstance(data, list) else [] + items = sorted(items, key=lambda x: x.get("localTradedAt", ""))[-days:] + result = [] + for x in items: + is_fall = x.get("compareToPreviousPrice", {}).get("code") in ("5", "4") + sign = -1 if is_fall else 1 + result.append({"date": x.get("localTradedAt", "")[:10], + "close": nf(x.get("closePrice", 0)), + "change": sign * nf(x.get("compareToPreviousClosePrice", 0)), + "change_pct": sign * nf(x.get("fluctuationsRatio", 0))}) + current = result[-1] if result else {} + return {"data": result, "current": current} + except Exception as e: + return {"data": [], "current": {}, "error": str(e)} + + +# ── 오늘의 투자 팁 ──────────────────────────────────────────── + +INVEST_TIPS = [ + {"title": "워렌 버핏의 첫 번째 규칙", "body": "절대 돈을 잃지 마라. 두 번째 규칙은 첫 번째 규칙을 절대 잊지 마라.", "tag": "버핏 철학"}, + {"title": "PER이란?", "body": "주가수익비율(PER) = 주가 ÷ EPS. 낮을수록 저평가 가능성이 높지만, 산업 평균과 비교해야 합니다. PER 10 미만이면 일반적으로 저평가 신호.", "tag": "기초 지표"}, + {"title": "ROE 15% 이상을 주목하라", "body": "자기자본이익률(ROE)은 기업이 자기 돈으로 얼마나 잘 버는지를 나타냅니다. 버핏은 ROE 15% 이상을 지속하는 기업을 선호합니다.", "tag": "재무 분석"}, + {"title": "분산 투자의 함정", "body": "피터 린치는 '덩어리 분산'을 경계했습니다. 20개 종목에 나눠도 같은 섹터면 위험이 분산되지 않습니다. 섹터와 성격이 다른 종목을 섞으세요.", "tag": "포트폴리오"}, + {"title": "거래량은 주가보다 먼저 움직인다", "body": "주가 상승 전에 거래량이 먼저 늘어나는 경우가 많습니다. 평소 거래량의 2배 이상이면 주목할 신호.", "tag": "기술적 분석"}, + {"title": "RSI 과매수·과매도 활용법", "body": "RSI 70 이상은 과매수(단기 조정 가능성), 30 이하는 과매도(반등 가능성). 단, 강한 추세장에서는 70 이상을 오래 유지하기도 합니다.", "tag": "기술적 분석"}, + {"title": "부채비율 200% 법칙", "body": "총부채 ÷ 자기자본. 200% 이하가 안정권이며, 업종 특성에 따라 다릅니다. 금융주·건설주는 높아도 정상, 제조업은 100% 이하가 이상적.", "tag": "재무 안전성"}, + {"title": "52주 신고가 전략", "body": "52주 신고가를 돌파하는 종목은 저항선이 없어 추가 상승 여력이 큽니다. 단, 거래량 수반 여부를 반드시 확인하세요.", "tag": "모멘텀"}, + {"title": "MACD 골든크로스", "body": "MACD선이 시그널선을 아래에서 위로 돌파할 때 매수 신호. 반대는 데드크로스(매도 신호). 다른 지표와 함께 쓸 때 신뢰도가 높아집니다.", "tag": "기술적 분석"}, + {"title": "배당수익률의 함정", "body": "배당수익률 = 주당배당금 ÷ 주가. 주가가 폭락해서 수익률이 높아 보이는 경우가 있습니다. 배당 지속성과 이익 대비 배당성향(60% 이하)을 함께 봐야 합니다.", "tag": "배당 투자"}, + {"title": "볼린저 밴드 활용", "body": "가격이 밴드 하단에 닿으면 과매도, 상단에 닿으면 과매수 신호. 밴드가 좁아질수록 변동성 폭발이 임박했다는 신호입니다.", "tag": "기술적 분석"}, + {"title": "외국인 수급을 따라가라", "body": "외국인이 5일 이상 연속 순매수하는 종목은 중기 상승 가능성이 높습니다. 단, 환율 변동에 민감하게 반응하므로 원달러 환율도 함께 확인하세요.", "tag": "수급 분석"}, + {"title": "공매도 잔고 주의", "body": "공매도 거래 비중이 5% 이상이면 기관의 하락 베팅이 강하다는 신호입니다. 반면 공매도 과다 종목이 호재를 만나면 숏스퀴즈로 급등하기도 합니다.", "tag": "수급 분석"}, + {"title": "이동평균선의 배열", "body": "MA5 > MA20 > MA60 > MA120 순서로 배열되면 '정배열'로 강한 상승추세. 반대는 '역배열'로 하락 추세를 의미합니다.", "tag": "기술적 분석"}, + {"title": "현금은 포지션이다", "body": "버핏은 적절한 투자 기회가 없을 때 현금을 60% 이상 보유했습니다. 현금은 수익률이 낮지만 다음 기회를 잡기 위한 최고의 무기입니다.", "tag": "버핏 철학"}, + {"title": "EPS 성장률을 보라", "body": "주당순이익(EPS)이 매년 10% 이상 성장하는 기업은 장기 투자 대상으로 적합합니다. 최근 3년간의 EPS 성장 추세를 확인하세요.", "tag": "성장 투자"}, + {"title": "매수는 분할로, 매도는 한 번에", "body": "매수 시 3번 나눠 분할 매수하면 평균 단가를 낮출 수 있습니다. 반대로 매도는 손절 라인에 닿으면 신속하게 결정하는 것이 좋습니다.", "tag": "매매 전략"}, + {"title": "실적발표 전후 전략", "body": "어닝 서프라이즈(예상 상회) 종목은 발표 후 3일~1주일 추가 상승하는 경향이 있습니다. 단, 호실적에도 주가가 빠지는 '소문에 사고 뉴스에 팔아라' 패턴도 주의하세요.", "tag": "이벤트 전략"}, + {"title": "손절은 원칙이다", "body": "매수가 대비 -7~8% 하락 시 무조건 손절하는 것이 큰 손실을 막는 가장 확실한 방법입니다. 한 종목에서 -30% 손실을 회복하려면 +43% 수익이 필요합니다.", "tag": "리스크 관리"}, + {"title": "시장 주도 섹터를 파악하라", "body": "AI/반도체, 바이오, 2차전지 등 시장을 이끄는 테마 섹터를 파악하고, 그 섹터 내에서 1~3위 기업에 집중하는 것이 개별 종목 리스크를 줄이는 방법입니다.", "tag": "섹터 분석"}, + {"title": "KOSPI와 개별 주식의 관계", "body": "코스피가 상승할 때 대형주가 먼저 움직이고, 이후 중소형주로 자금이 흘러가는 경향이 있습니다. 코스피 레벨을 보면 중소형주 진입 타이밍을 가늠할 수 있습니다.", "tag": "시장 분석"}, + {"title": "PBR 1배 미만 = 청산가치 이하", "body": "주가순자산비율(PBR)이 1 미만이면 이론상 회사를 청산했을 때 받을 금액보다 시가총액이 낮습니다. 구조조정 리스크가 없다면 안전마진이 있는 저평가 종목입니다.", "tag": "가치 투자"}, + {"title": "영업현금흐름을 믿어라", "body": "순이익보다 영업현금흐름(OCF)이 더 중요합니다. OCF가 순이익보다 크면 이익의 질이 높은 기업입니다. 반대면 분식회계 가능성도 점검해 보세요.", "tag": "재무 분석"}, + {"title": "코스피 2500 이하는 역사적 저점대", "body": "코스피 2500은 과거 통계상 12개월 내 반등 확률이 높은 구간입니다. 단, 경제 위기나 금리 급등 시에는 예외가 있으므로 매크로 환경을 함께 살피세요.", "tag": "시장 분석"}, + {"title": "분기 실적의 계절성", "body": "1분기(1~3월)는 대부분 기업의 비수기입니다. 4분기 실적을 확인하고 1분기 초에 진입, 2~3분기 성수기를 노리는 계절적 전략이 유효합니다.", "tag": "계절성"}, + {"title": "신규 상장주 주의", "body": "IPO 첫날 급등 후 3~6개월 보호예수 해제 시 대주주 물량이 쏟아져 주가 하락하는 경우가 많습니다. 상장 6개월 이후 기본기가 확인된 종목을 노리는 것이 안전합니다.", "tag": "리스크 관리"}, + {"title": "스토캐스틱 활용법", "body": "스토캐스틱 %K가 20 이하면 과매도, 80 이상이면 과매수 구간. %K가 %D를 아래에서 돌파하면 매수, 위에서 하향 돌파하면 매도 신호입니다.", "tag": "기술적 분석"}, + {"title": "인플레이션과 주식", "body": "인플레이션이 높을 때는 가격 전가력이 있는 기업(필수소비재, 에너지, 원자재)이 유리합니다. 금리 인하 사이클에 진입하면 성장주·배당주로 자금이 이동하는 경향이 있습니다.", "tag": "매크로"}, + {"title": "환율과 수출주", "body": "원달러 환율이 오르면(원화 약세) 삼성전자·현대차 등 수출 비중이 높은 기업의 이익이 증가합니다. 1원 상승 시 삼성전자 영업이익은 약 500억 원 증가하는 것으로 알려져 있습니다.", "tag": "매크로"}, + {"title": "기관 투자자 동향", "body": "기관이 3일 이상 연속 순매수하는 종목은 중기 상승 압력이 있습니다. 기관의 연기금(국민연금 등)은 코스피 2500 이하에서 주식 비중을 높이는 경향이 있습니다.", "tag": "수급 분석"}, + {"title": "밸류에이션 상대 비교", "body": "같은 섹터 내에서 PER, PBR을 비교하는 것이 절대 수치보다 유용합니다. 섹터 평균 PER 대비 20% 이상 저평가된 기업을 찾아보세요.", "tag": "가치 투자"}, +] + +@app.get("/api/daily-tip") +async def daily_tip(): + """날짜 기반 오늘의 투자 팁 (매일 자동 변경)""" + day_of_year = datetime.now().timetuple().tm_yday + tip = INVEST_TIPS[day_of_year % len(INVEST_TIPS)] + return tip + + +# ── 정적 파일 / 루트 ───────────────────────────────────────── + +# ── Kiwoom 실시간 프록시 ─────────────────────────────────── + +@app.get("/api/minute/{code}") +async def minute(code: str, scope: str = Query(default="5")): + """분봉 차트 (kis-api 프록시)""" + async with httpx.AsyncClient() as c: + try: + r = await c.get(f"{KIS_API_URL}/minute/{code}?scope={scope}", timeout=20) + return JSONResponse(content=r.json()) + except Exception as e: + return JSONResponse(content={"code": code, "data": [], "error": str(e)}) + +@app.get("/api/orderbook/{code}") +async def orderbook(code: str): + """호가 (kis-api 프록시)""" + async with httpx.AsyncClient() as c: + try: + r = await c.get(f"{KIS_API_URL}/orderbook/{code}", timeout=10) + return JSONResponse(content=r.json()) + except Exception as e: + return JSONResponse(content={"code": code, "ask": [], "bid": [], "error": str(e)}) + +@app.get("/api/volume-surge") +async def volume_surge(): + """거래량 급증 종목 TOP (kis-api 프록시)""" + async with httpx.AsyncClient() as c: + try: + r = await c.get(f"{KIS_API_URL}/volume-surge", timeout=15) + return JSONResponse(content=r.json()) + except Exception as e: + return JSONResponse(content={"data": [], "error": str(e)}) + +# ── 사용자 인증 + 포트폴리오 ──────────────────────────────── + +class RegisterReq(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=100) + +class LoginReq(BaseModel): + email: EmailStr + password: str + +class PortfolioItemReq(BaseModel): + stock_code: str = Field(min_length=6, max_length=6) + stock_name: str = "" + buy_price: int = Field(gt=0) + qty: int = Field(gt=0) + memo: str = "" + +async def _require_admin(user: dict = Depends(current_user)) -> dict: + async with pg_pool.acquire() as c: + role = await c.fetchval("SELECT role FROM users WHERE id=$1", user["id"]) + if role != "admin": + raise HTTPException(status_code=403, detail="관리자 권한이 필요합니다") + return user + +@app.post("/api/auth/register") +async def auth_register(req: RegisterReq, request: Request): + ip = _client_ip(request) + await _rate_limit(f"auth:rl:reg:{ip}", RL_REGISTER_MAX, RL_REGISTER_WINDOW) + + is_admin = req.email.lower() in ADMIN_EMAILS + role = "admin" if is_admin else "user" + is_approved = is_admin # 관리자만 즉시 승인 + + async with pg_pool.acquire() as c: + existing = await c.fetchval("SELECT id FROM users WHERE email=$1", req.email) + if existing: + # 이메일 존재 노출 방지: 가입 처리된 것처럼 보이는 일반 메시지 + raise HTTPException(status_code=409, detail="가입할 수 없는 이메일입니다") + row = await c.fetchrow(""" + INSERT INTO users(email, password_hash, role, is_approved) + VALUES($1,$2,$3,$4) + RETURNING id, email, role, is_approved + """, req.email, hash_password(req.password), role, is_approved) + + if not is_approved: + return { + "pending": True, + "user": {"id": row["id"], "email": row["email"]}, + "message": "회원가입이 접수되었습니다. 관리자 승인 후 로그인 가능합니다." + } + token = create_token(row["id"], row["email"]) + return { + "access_token": token, + "user": {"id": row["id"], "email": row["email"], "role": row["role"]}, + } + +@app.post("/api/auth/login") +async def auth_login(req: LoginReq, request: Request): + ip = _client_ip(request) + await _rate_limit(f"auth:rl:login:{ip}", RL_LOGIN_MAX, RL_LOGIN_WINDOW) + + async with pg_pool.acquire() as c: + row = await c.fetchrow(""" + SELECT id, email, password_hash, role, is_approved, + failed_login_count, locked_until + FROM users WHERE email=$1 + """, req.email) + + # 사용자 없음 → 더미 verify로 응답시간 균일화 + if not row: + dummy_verify() + raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다") + + # 잠금 상태 체크 + if row["locked_until"]: + now = datetime.now(timezone.utc) + if row["locked_until"] > now: + remaining = int((row["locked_until"] - now).total_seconds()) + raise HTTPException( + status_code=423, + detail=f"로그인 시도가 너무 많습니다. {remaining // 60 + 1}분 후 다시 시도하세요") + + # 비밀번호 검증 + if not verify_password(req.password, row["password_hash"]): + new_count = row["failed_login_count"] + 1 + lock_until = None + if new_count >= LOCK_THRESHOLD: + lock_until = datetime.now(timezone.utc) + timedelta(seconds=LOCK_DURATION) + await c.execute(""" + UPDATE users SET failed_login_count=$1, locked_until=$2 WHERE id=$3 + """, new_count, lock_until, row["id"]) + raise HTTPException(status_code=401, detail="이메일 또는 비밀번호가 올바르지 않습니다") + + # 승인 게이트 + if not row["is_approved"]: + raise HTTPException(status_code=403, detail="관리자 승인 대기 중입니다") + + # 성공 → 카운터 리셋 + await c.execute(""" + UPDATE users SET failed_login_count=0, locked_until=NULL, last_login_at=NOW() + WHERE id=$1 + """, row["id"]) + + token = create_token(row["id"], row["email"]) + return { + "access_token": token, + "user": {"id": row["id"], "email": row["email"], "role": row["role"]}, + } + +@app.get("/api/auth/me") +async def auth_me(user: dict = Depends(current_user)): + async with pg_pool.acquire() as c: + row = await c.fetchrow(""" + SELECT id, email, role, is_approved, created_at, last_login_at + FROM users WHERE id=$1 + """, user["id"]) + if not row: + raise HTTPException(status_code=404, detail="user not found") + if not row["is_approved"]: + raise HTTPException(status_code=403, detail="승인되지 않은 계정") + return { + "id": row["id"], "email": row["email"], "role": row["role"], + "is_approved": row["is_approved"], + "created_at": str(row["created_at"]), + "last_login_at": str(row["last_login_at"]) if row["last_login_at"] else None, + } + +# ── 관리자 전용: 회원 목록/승인/삭제 ─────────────────────── + +@app.get("/api/admin/users") +async def admin_list_users(_: dict = Depends(_require_admin)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT id, email, role, is_approved, created_at, last_login_at, + failed_login_count, locked_until + FROM users ORDER BY is_approved ASC, created_at DESC + """) + return [ + { + "id": r["id"], "email": r["email"], "role": r["role"], + "is_approved": r["is_approved"], + "created_at": str(r["created_at"]), + "last_login_at": str(r["last_login_at"]) if r["last_login_at"] else None, + "failed_login_count": r["failed_login_count"], + "locked_until": str(r["locked_until"]) if r["locked_until"] else None, + } + for r in rows + ] + +@app.post("/api/admin/users/{user_id}/approve") +async def admin_approve_user(user_id: int, _: dict = Depends(_require_admin)): + async with pg_pool.acquire() as c: + result = await c.execute( + "UPDATE users SET is_approved=true WHERE id=$1", user_id) + if result.endswith(" 0"): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다") + return {"approved": user_id} + +@app.post("/api/admin/users/{user_id}/unlock") +async def admin_unlock_user(user_id: int, _: dict = Depends(_require_admin)): + async with pg_pool.acquire() as c: + result = await c.execute(""" + UPDATE users SET failed_login_count=0, locked_until=NULL WHERE id=$1 + """, user_id) + if result.endswith(" 0"): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다") + return {"unlocked": user_id} + +@app.delete("/api/admin/users/{user_id}") +async def admin_delete_user(user_id: int, admin: dict = Depends(_require_admin)): + if user_id == admin["id"]: + raise HTTPException(status_code=400, detail="자기 자신은 삭제할 수 없습니다") + async with pg_pool.acquire() as c: + result = await c.execute("DELETE FROM users WHERE id=$1", user_id) + if result.endswith(" 0"): + raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다") + return {"deleted": user_id} + +@app.get("/api/portfolio") +async def portfolio_list(user: dict = Depends(current_user)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT id, stock_code, stock_name, buy_price, qty, memo, + created_at, updated_at + FROM user_portfolio WHERE user_id=$1 + ORDER BY created_at DESC + """, user["id"]) + return [ + {**dict(r), "created_at": str(r["created_at"]), "updated_at": str(r["updated_at"])} + for r in rows + ] + +@app.post("/api/portfolio") +async def portfolio_add(req: PortfolioItemReq, user: dict = Depends(current_user)): + async with pg_pool.acquire() as c: + row = await c.fetchrow(""" + INSERT INTO user_portfolio(user_id, stock_code, stock_name, buy_price, qty, memo) + VALUES($1,$2,$3,$4,$5,$6) + RETURNING id, stock_code, stock_name, buy_price, qty, memo, created_at, updated_at + """, user["id"], req.stock_code, req.stock_name, req.buy_price, req.qty, req.memo) + return {**dict(row), "created_at": str(row["created_at"]), "updated_at": str(row["updated_at"])} + +@app.put("/api/portfolio/{item_id}") +async def portfolio_update(item_id: int, req: PortfolioItemReq, user: dict = Depends(current_user)): + async with pg_pool.acquire() as c: + row = await c.fetchrow(""" + UPDATE user_portfolio + SET stock_code=$1, stock_name=$2, buy_price=$3, qty=$4, memo=$5, updated_at=NOW() + WHERE id=$6 AND user_id=$7 + RETURNING id, stock_code, stock_name, buy_price, qty, memo, created_at, updated_at + """, req.stock_code, req.stock_name, req.buy_price, req.qty, req.memo, + item_id, user["id"]) + if not row: + raise HTTPException(status_code=404, detail="포지션을 찾을 수 없습니다") + return {**dict(row), "created_at": str(row["created_at"]), "updated_at": str(row["updated_at"])} + +@app.delete("/api/portfolio/{item_id}") +async def portfolio_delete(item_id: int, user: dict = Depends(current_user)): + async with pg_pool.acquire() as c: + result = await c.execute( + "DELETE FROM user_portfolio WHERE id=$1 AND user_id=$2", + item_id, user["id"]) + if result.endswith(" 0"): + raise HTTPException(status_code=404, detail="포지션을 찾을 수 없습니다") + return {"deleted": item_id} + +@app.post("/api/portfolio/import") +async def portfolio_import(items: list[PortfolioItemReq], user: dict = Depends(current_user)): + """localStorage → 서버 1회 마이그레이션. 동일 stock_code 중복 시 skip.""" + inserted = 0 + skipped = 0 + async with pg_pool.acquire() as c: + existing = await c.fetch( + "SELECT stock_code FROM user_portfolio WHERE user_id=$1", user["id"]) + existing_codes = {r["stock_code"] for r in existing} + for it in items: + if it.stock_code in existing_codes: + skipped += 1 + continue + await c.execute(""" + INSERT INTO user_portfolio(user_id, stock_code, stock_name, buy_price, qty, memo) + VALUES($1,$2,$3,$4,$5,$6) + """, user["id"], it.stock_code, it.stock_name, it.buy_price, it.qty, it.memo) + inserted += 1 + return {"inserted": inserted, "skipped": skipped} + + +# ═══════════════════════════════════════════════════════════ +# 증권사 리서치 데스크 도구 (거시·비교·워치리스트·캘린더) +# ═══════════════════════════════════════════════════════════ + +MACRO_TARGETS = { + "KOSPI": "^KS11", "KOSDAQ": "^KQ11", + "S&P500": "^GSPC", "NASDAQ": "^IXIC", "DOW": "^DJI", + "NIKKEI": "^N225", "HSI": "^HSI", + "VIX": "^VIX", + "USD/KRW": "KRW=X", "JPY/KRW": "KRWJPY=X", + "GOLD": "GC=F", "WTI": "CL=F", + "US10Y": "^TNX", +} + +async def _fetch_macro_one(client: httpx.AsyncClient, symbol: str) -> dict: + try: + r = await client.get( + f"https://query1.finance.yahoo.com/v8/finance/chart/{symbol}?range=2d&interval=1d", + headers={"User-Agent": "Mozilla/5.0"}, timeout=6) + if r.status_code != 200: return {} + meta = r.json()["chart"]["result"][0]["meta"] + cur = meta.get("regularMarketPrice") + prev = meta.get("chartPreviousClose") + chg_pct = (cur - prev) / prev * 100 if cur and prev else 0 + return {"price": cur, "prev": prev, "change_pct": round(chg_pct, 2)} + except Exception: + return {} + +@app.get("/api/macro") +async def macro(): + """글로벌 거시지표 일괄 fetch (Yahoo Finance + 60초 Redis 캐시)""" + cache_key = "macro:dashboard" + if redis_cl: + try: + cached = await redis_cl.get(cache_key) + if cached: return json.loads(cached) + except: pass + async with httpx.AsyncClient() as c: + results = await asyncio.gather(*[ + _fetch_macro_one(c, sym) for sym in MACRO_TARGETS.values() + ], return_exceptions=True) + out = {} + for (name, _), res in zip(MACRO_TARGETS.items(), results): + if isinstance(res, dict) and res.get("price") is not None: + out[name] = res + out["_ts"] = datetime.now().isoformat() + if redis_cl: + try: await redis_cl.setex(cache_key, 60, json.dumps(out)) + except: pass + return out + + +@app.get("/api/compare") +async def compare(codes: str = Query(..., description="콤마로 구분된 종목코드")): + """N개 종목 동시 비교 + 동종업계 평균""" + code_list = [c.strip() for c in codes.split(",") if c.strip()][:10] + if not code_list: return {"error": "codes 필수"} + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT s.stock_code, s.stock_name, s.total_score, s.recommendation, + s.magic_score, s.f_score, s.altman_z, s.peg, s.momentum_pct, + s.beneish_score, s.buy_votes, s.sell_votes, s.sector, + f.roe, f.operating_margin, f.debt_ratio, f.fcf_ratio, + f.revenue_growth, f.bsns_year, + p.price, p.per, p.pbr, p.market_cap, p.change_pct, p.collected_at + FROM dart_corps d + LEFT JOIN stock_scores s + ON s.stock_code=d.stock_code AND s.score_date=(SELECT MAX(score_date) FROM stock_scores) + LEFT JOIN dart_financials f + ON f.stock_code=d.stock_code AND f.reprt_code='11011' + AND f.bsns_year=(SELECT MAX(bsns_year) FROM dart_financials f2 + WHERE f2.stock_code=d.stock_code AND f2.reprt_code='11011') + LEFT JOIN LATERAL ( + SELECT price, per, pbr, market_cap, change_pct, collected_at + FROM stock_prices WHERE stock_code=d.stock_code + ORDER BY collected_at DESC LIMIT 1 + ) p ON true + WHERE d.stock_code = ANY($1) AND d.is_active=true + """, code_list) + items = [dict(r) for r in rows] + sector_summary = {} + for sec in {i["sector"] for i in items if i.get("sector")}: + async with pg_pool.acquire() as c: + avg = await c.fetchrow(""" + SELECT AVG(NULLIF(p.per,0)) AS avg_per, AVG(NULLIF(p.pbr,0)) AS avg_pbr, + AVG(NULLIF(f.roe,0)) AS avg_roe, AVG(NULLIF(f.debt_ratio,0)) AS avg_debt, + COUNT(*) AS n + FROM dart_corps d + LEFT JOIN dart_financials f ON f.stock_code=d.stock_code AND f.reprt_code='11011' + LEFT JOIN LATERAL (SELECT per, pbr FROM stock_prices WHERE stock_code=d.stock_code + ORDER BY collected_at DESC LIMIT 1) p ON true + WHERE d.sector=$1 AND d.is_active=true AND p.per > 0 AND p.per < 100 + """, sec) + if avg: + sector_summary[sec] = { + "avg_per": round(float(avg["avg_per"] or 0), 1), + "avg_pbr": round(float(avg["avg_pbr"] or 0), 2), + "avg_roe": round(float(avg["avg_roe"] or 0), 1), + "avg_debt_ratio": round(float(avg["avg_debt"] or 0), 1), + "n_peers": int(avg["n"] or 0), + } + return {"stocks": items, "sectors": sector_summary, "n": len(code_list)} + + +@app.get("/api/watchlist") +async def watchlist_get(user_id: int = Query(...)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT w.stock_code, w.memo, w.added_at, d.corp_name, d.sector, + s.total_score, s.recommendation, s.buy_votes, s.sell_votes, + p.price, p.change_pct, p.per, p.pbr + FROM user_watchlist w + LEFT JOIN dart_corps d ON d.stock_code=w.stock_code + LEFT JOIN stock_scores s ON s.stock_code=w.stock_code + AND s.score_date=(SELECT MAX(score_date) FROM stock_scores) + LEFT JOIN LATERAL (SELECT price, change_pct, per, pbr FROM stock_prices + WHERE stock_code=w.stock_code ORDER BY collected_at DESC LIMIT 1) p ON true + WHERE w.user_id=$1 ORDER BY w.added_at DESC + """, user_id) + return [dict(r) for r in rows] + +@app.post("/api/watchlist") +async def watchlist_add(req: dict): + user_id = req.get("user_id"); code = req.get("stock_code"); memo = req.get("memo", "") + if not user_id or not code: return {"error": "user_id, stock_code 필수"} + async with pg_pool.acquire() as c: + await c.execute(""" + INSERT INTO user_watchlist (user_id, stock_code, memo) + VALUES ($1, $2, $3) ON CONFLICT (user_id, stock_code) DO UPDATE SET memo=$3 + """, user_id, code, memo) + return {"status": "added", "stock_code": code} + +@app.delete("/api/watchlist/{code}") +async def watchlist_remove(code: str, user_id: int = Query(...)): + async with pg_pool.acquire() as c: + await c.execute("DELETE FROM user_watchlist WHERE user_id=$1 AND stock_code=$2", user_id, code) + return {"status": "removed", "stock_code": code} + + +async def _send_telegram(msg: str): + """텔레그램 알림 발송 (TG 토큰/채팅ID 환경변수 사용)""" + token = os.getenv("TELEGRAM_BOT_TOKEN", "") + chat_id = os.getenv("TELEGRAM_CHAT_ID", "") + if not token or not chat_id: return + try: + async with httpx.AsyncClient() as c: + await c.post(f"https://api.telegram.org/bot{token}/sendMessage", + json={"chat_id": chat_id, "text": msg, "parse_mode": "HTML"}, + timeout=10) + except: pass + + +async def check_alerts(): + """활성 알림 조건 체크 → 발화 시 텔레그램 + last_triggered 업데이트""" + async with pg_pool.acquire() as c: + alerts = await c.fetch(""" + SELECT a.id, a.stock_code, a.alert_type, a.threshold, a.user_id, + d.corp_name + FROM user_alerts a + LEFT JOIN dart_corps d ON d.stock_code=a.stock_code + WHERE a.active=true + AND (a.last_triggered IS NULL OR a.last_triggered < NOW()-INTERVAL '6 hours') + """) + for a in alerts: + try: + code = a["stock_code"]; t = a["alert_type"]; thr = float(a["threshold"]) + cur = None; ind = None + # Redis price → fallback DB + if redis_cl: + p = await redis_cl.get(f"price:{code}") + if p: cur = float(json.loads(p).get("price") or 0) + if not cur: + async with pg_pool.acquire() as c: + pr = await c.fetchrow("SELECT price FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1", code) + if pr: cur = float(pr["price"] or 0) + # 기술 지표 (MA·RSI 알림용) + async with pg_pool.acquire() as c: + tech = await c.fetchrow("SELECT ma20, ma60, rsi FROM stock_technical WHERE stock_code=$1", code) + score_row = await c.fetchrow("SELECT total_score FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1", code) + triggered = False; msg = "" + name = a["corp_name"] or code + if t == "price_above" and cur and cur > thr: + triggered = True; msg = f"💹 {name} ({code}) 가격 도달\n현재가 {cur:,.0f} > 목표 {thr:,.0f}" + elif t == "price_below" and cur and cur < thr: + triggered = True; msg = f"📉 {name} ({code}) 가격 하락\n현재가 {cur:,.0f} < 손절 {thr:,.0f}" + elif t == "rsi_above" and tech and tech["rsi"] and tech["rsi"] > thr: + triggered = True; msg = f"⚠️ {name} RSI {tech['rsi']:.1f} > {thr} (과매수)" + elif t == "rsi_below" and tech and tech["rsi"] and tech["rsi"] < thr: + triggered = True; msg = f"📊 {name} RSI {tech['rsi']:.1f} < {thr} (과매도)" + elif t == "ma20_break_up" and cur and tech and tech["ma20"] and cur > tech["ma20"] * (1 + thr/100): + triggered = True; msg = f"📈 {name} MA20 돌파 +{thr}% ({cur:,.0f})" + elif t == "ma20_break_dn" and cur and tech and tech["ma20"] and cur < tech["ma20"] * (1 - thr/100): + triggered = True; msg = f"📉 {name} MA20 이탈 -{thr}% ({cur:,.0f})" + elif t == "score_above" and score_row and score_row["total_score"] > thr: + triggered = True; msg = f"⭐ {name} 종합점수 {score_row['total_score']:.1f} > {thr}" + elif t == "score_below" and score_row and score_row["total_score"] < thr: + triggered = True; msg = f"⚠️ {name} 종합점수 {score_row['total_score']:.1f} < {thr}" + if triggered: + await _send_telegram(msg) + async with pg_pool.acquire() as c: + await c.execute("UPDATE user_alerts SET last_triggered=NOW() WHERE id=$1", a["id"]) + except Exception: + pass + + +@app.get("/api/alerts/list") +async def alerts_list(user_id: int = Query(...)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT a.*, d.corp_name FROM user_alerts a + LEFT JOIN dart_corps d ON d.stock_code=a.stock_code + WHERE a.user_id=$1 ORDER BY a.created_at DESC + """, user_id) + return [dict(r) for r in rows] + +@app.post("/api/alerts/create") +async def alerts_create(req: dict): + user_id = req.get("user_id"); code = req.get("stock_code") + t = req.get("alert_type"); thr = req.get("threshold") + valid = ("price_above","price_below","rsi_above","rsi_below", + "ma20_break_up","ma20_break_dn","score_above","score_below") + if not all([user_id, code, t]) or t not in valid: + return {"error": f"alert_type ∈ {valid}"} + async with pg_pool.acquire() as c: + await c.execute(""" + INSERT INTO user_alerts (user_id, stock_code, alert_type, threshold) + VALUES ($1,$2,$3,$4) + """, user_id, code, t, float(thr)) + return {"status": "created"} + +@app.delete("/api/alerts/{alert_id}") +async def alerts_delete(alert_id: int, user_id: int = Query(...)): + async with pg_pool.acquire() as c: + await c.execute("DELETE FROM user_alerts WHERE id=$1 AND user_id=$2", alert_id, user_id) + return {"status": "deleted"} + + +@app.get("/api/calendar") +async def calendar_api(days: int = Query(default=30, ge=1, le=180)): + """이벤트 캘린더 — 향후 N일 실적·배당·매크로(FOMC/BOK)""" + today = date.today() + until = today + timedelta(days=days) + async with pg_pool.acquire() as c: + recent = await c.fetch(""" + SELECT primary_stock AS code, title, sentiment, intensity, + published_at::date AS dt, catalyst, source + FROM news_analysis + WHERE source='DART공시' AND published_at::date >= $1 + ORDER BY published_at DESC LIMIT 50 + """, today - timedelta(days=7)) + macro_events = [ + {"date": "2026-06-12", "event": "FOMC 회의", "impact": "글로벌 금리"}, + {"date": "2026-06-13", "event": "한국은행 금통위", "impact": "원화·금리"}, + {"date": "2026-07-10", "event": "FOMC 회의", "impact": "글로벌 금리"}, + {"date": "2026-08-22", "event": "Jackson Hole", "impact": "글로벌 자산"}, + ] + macro_events = [e for e in macro_events + if today <= datetime.strptime(e["date"], "%Y-%m-%d").date() <= until] + return {"period": {"from": str(today), "to": str(until)}, + "macro_events": macro_events, + "recent_dart": [dict(r) for r in recent][:30]} + + +@app.get("/") +async def index(): + return FileResponse("/app/index.html") + + +@app.get("/cards") +async def cards_page(): + """종목 카드형 대시보드 (신규 직관 UI)""" + return FileResponse("/app/cards.html") diff --git a/dashboard-api/migrations/001_users_portfolio.sql b/dashboard-api/migrations/001_users_portfolio.sql new file mode 100644 index 0000000..6a9331f --- /dev/null +++ b/dashboard-api/migrations/001_users_portfolio.sql @@ -0,0 +1,26 @@ +-- 사용자 인증 + 포트폴리오 (5~20명 지인 대상, JWT 기반) +-- idempotent: 여러 번 실행해도 안전 + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ +); + +CREATE TABLE IF NOT EXISTS user_portfolio ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) NOT NULL DEFAULT '', + buy_price INTEGER NOT NULL, + qty INTEGER NOT NULL, + memo TEXT DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_portfolio_user ON user_portfolio(user_id); +CREATE INDEX IF NOT EXISTS idx_user_portfolio_user_code ON user_portfolio(user_id, stock_code); diff --git a/dashboard-api/migrations/002_user_approval.sql b/dashboard-api/migrations/002_user_approval.sql new file mode 100644 index 0000000..9425c1c --- /dev/null +++ b/dashboard-api/migrations/002_user_approval.sql @@ -0,0 +1,9 @@ +-- 회원가입 관리자 승인 + 보안 (계정 잠금) +-- ADMIN_EMAILS 환경변수에 매칭되는 이메일은 register 시 자동 승인+admin + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS is_approved BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS failed_login_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS locked_until TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_users_pending ON users(is_approved) WHERE is_approved = false; diff --git a/docker-compose-all-phases.yml b/docker-compose-all-phases.yml new file mode 100644 index 0000000..61b532a --- /dev/null +++ b/docker-compose-all-phases.yml @@ -0,0 +1,177 @@ +# ============================================================ +# docker-compose.yml services: 블록 안에 추가 +# Phase 1~4 전체 서비스 +# ============================================================ + + # ── Phase 1: 네이버 뉴스 수집기 ────────────────────────── + news-collector: + build: + context: ./news-collector + dockerfile: Dockerfile + container_name: trading-news-collector + restart: unless-stopped + ports: + - "8787:8787" + environment: + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + BAREUN_API_URL: "http://bareunaapi:5757" + OLLAMA_URL: "http://ollama:11434" + VLLM_URL: "http://vllm:8000" + QDRANT_URL: "http://qdrant:6333" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.16 + depends_on: + redis: + condition: service_healthy + bareunaapi: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8787/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" + + # ── Phase 2: 한국투자증권 주가 API ────────────────────── + kis-api: + build: + context: ./kis-api + dockerfile: Dockerfile + container_name: trading-kis-api + restart: unless-stopped + ports: + - "8585:8585" + environment: + KIS_APP_KEY: "${KIS_APP_KEY:-}" + KIS_APP_SECRET: "${KIS_APP_SECRET:-}" + KIS_IS_PAPER: "${KIS_IS_PAPER:-true}" + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.18 + depends_on: + redis: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8585/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 3: 종목 점수 엔진 ───────────────────────────── + score-engine: + build: + context: ./score-engine + dockerfile: Dockerfile + container_name: trading-score-engine + restart: unless-stopped + ports: + - "8686:8686" + environment: + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.19 + depends_on: + redis: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8686/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 4: 대시보드 API ─────────────────────────────── + dashboard-api: + build: + context: ./dashboard-api + dockerfile: Dockerfile + container_name: trading-dashboard-api + restart: unless-stopped + ports: + - "8989:8989" + environment: + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.22 + depends_on: + redis: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD-SHELL", "true"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..11ddcac --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,830 @@ + + +# ============================================================ +# Trading AI System +# GPU 0: RTX 3060 12GB → Ollama (EXAONE 3.5 추론) +# GPU 1: RTX 3070 8GB → Ollama (bge-m3 임베딩) +# ============================================================ + +x-logging: &default-logging + driver: "json-file" + options: + max-size: "${LOG_MAX_SIZE:-100m}" + max-file: "${LOG_MAX_FILE:-10}" + +x-restart: &restart + restart: unless-stopped + +networks: + trading-net: + driver: bridge + ipam: + config: + - subnet: "${SUBNET:-172.30.0.0/16}" + gateway: "${GATEWAY:-172.30.0.1}" + +volumes: + redis-data: + qdrant-data: + ollama-data: + n8n-data: + bareun-data: + pgdata: + +services: + + # ── Phase 1: 네이버 뉴스 수집기 ────────────────────────── + news-collector: + build: + context: ./news-collector + dockerfile: Dockerfile + container_name: trading-news-collector + restart: unless-stopped + init: true + ports: + - "8787:8787" + environment: + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + BAREUN_API_URL: "http://bareunaapi:5757" + OLLAMA_URL: "http://ollama:11434" + QDRANT_URL: "http://qdrant:6333" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.16 + depends_on: + redis: + condition: service_healthy + bareunaapi: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8787/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "5" + + # ── Phase 2: 한국투자증권 주가 API ────────────────────── + kis-api: + build: + context: ./kis-api + dockerfile: Dockerfile + container_name: trading-kis-api + restart: unless-stopped + init: true + ports: + - "8585:8585" + environment: + KIWOOM_APP_KEY: "${KIWOOM_APP_KEY}" + KIWOOM_SECRET_KEY: "${KIWOOM_SECRET_KEY}" + KIWOOM_BASE_URL: "${KIWOOM_BASE_URL:-https://api.kiwoom.com}" + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.18 + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8585/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 2.5: 기술적 분석 엔진 ────────────────────────── + ta-engine: + build: + context: ./ta-engine + dockerfile: Dockerfile + container_name: trading-ta-engine + restart: unless-stopped + init: true + ports: + - "8484:8484" + environment: + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + OLLAMA_URL: "http://ollama:11434" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.23 + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8484/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 3: 종목 점수 엔진 ───────────────────────────── + score-engine: + build: + context: ./score-engine + dockerfile: Dockerfile + container_name: trading-score-engine + restart: unless-stopped + init: true + ports: + - "8686:8686" + environment: + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" + TELEGRAM_CHAT_ID: "${TELEGRAM_CHAT_ID:-}" + ECOS_API_KEY: "${ECOS_API_KEY:-}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.19 + depends_on: + redis: + condition: service_healthy + ta-engine: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8686/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 3.7: 텔레그램 양방향 봇 ─────────────────────── + telegram-bot: + build: + context: ./telegram-bot + dockerfile: Dockerfile + container_name: trading-telegram-bot + restart: unless-stopped + environment: + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN}" + TELEGRAM_CHAT_ID: "${TELEGRAM_CHAT_ID}" + OLLAMA_URL: "http://ollama:11434" + EXAONE_MODEL: "exaone3.5:7.8b" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.26 + depends_on: + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 3.6: 보조 시그널 (컨센서스/기관수급/매크로) ── + aux-signal: + build: + context: ./aux-signal + dockerfile: Dockerfile + container_name: trading-aux-signal + restart: unless-stopped + init: true + ports: + - "8282:8282" + environment: + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + ECOS_API_KEY: "${ECOS_API_KEY:-}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.25 + depends_on: + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8282/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 3.5: 미국증시 동조 시그널 ───────────────────── + us-market: + build: + context: ./us-market + dockerfile: Dockerfile + container_name: trading-us-market + restart: unless-stopped + init: true + ports: + - "8383:8383" + environment: + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + FINNHUB_API_KEY: "${FINNHUB_API_KEY:-}" + ALPHAVANTAGE_API_KEY: "${ALPHAVANTAGE_API_KEY:-}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.24 + depends_on: + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8383/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 5: 그래프 신경망 (GAT) ──────────────────────── + graph-engine: + build: + context: ./graph-engine + dockerfile: Dockerfile + container_name: trading-graph-engine + restart: unless-stopped + init: true + ports: + - "9090:9090" + environment: + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + GRAPH_MODEL_DIR: "/mnt/nas/models/graph" + TZ: "Asia/Seoul" + volumes: + - /mnt/nas/models:/mnt/nas/models + networks: + trading-net: + ipv4_address: 172.30.0.27 + depends_on: + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "4.0" + memory: 4G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:9090/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ── Phase 4: 대시보드 API ─────────────────────────────── + dashboard-api: + build: + context: ./dashboard-api + dockerfile: Dockerfile + container_name: trading-dashboard-api + restart: unless-stopped + init: true + ports: + - "8989:8989" + environment: + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + JWT_SECRET: "${JWT_SECRET}" + JWT_EXPIRE_DAYS: "${JWT_EXPIRE_DAYS}" + ADMIN_EMAILS: "${ADMIN_EMAILS}" + TRUSTED_ORIGINS: "${TRUSTED_ORIGINS}" + TZ: "Asia/Seoul" + networks: + trading-net: + ipv4_address: 172.30.0.22 + depends_on: + redis: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD-SHELL", "true"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + logging: + driver: "json-file" + options: + max-size: "50m" + max-file: "5" + + # ────────────────────────────────────────── + # DART 공시 수집기 172.30.0.17 + # ────────────────────────────────────────── + dart-collector: + build: + context: ./dart-collector + dockerfile: Dockerfile + container_name: trading-dart-collector + restart: unless-stopped + init: true + ports: + - "8888:8888" + environment: + DART_API_KEY: "${DART_API_KEY}" + REDIS_HOST: redis + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_PORT: "${POSTGRES_PORT}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + BAREUN_API_URL: "http://bareunaapi:5757" + OLLAMA_URL: "http://ollama:11434" + QDRANT_URL: "http://qdrant:6333" + TZ: "Asia/Seoul" + N8N_EDITOR_BASE_URL: "https://n8.kyleyang.co.kr" + WEBHOOK_URL: "https://n8.kyleyang.co.kr" + N8N_HOST: "0.0.0.0" + N8N_LISTEN_ADDRESS: "0.0.0.0" + networks: + trading-net: + ipv4_address: 172.30.0.17 + depends_on: + redis: + condition: service_healthy + bareunaapi: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + healthcheck: + test: ["CMD-SHELL", "python3 -c \"import urllib.request; urllib.request.urlopen('http://localhost:8888/health')\" 2>/dev/null || exit 1"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "100m" + max-file: "10" + + # ────────────────────────────────────────── + # ────────────────────────────────────────── + # PostgreSQL 172.30.0.9 + # ────────────────────────────────────────── + postgres: + image: postgres:16 + container_name: trading-postgres + <<: *restart + ports: + - "55432:5432" + environment: + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + POSTGRES_DB: "${POSTGRES_DB}" + PGDATA: /var/lib/postgresql/data + TZ: "Asia/Seoul" + volumes: + - pgdata:/var/lib/postgresql/data + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + networks: + trading-net: + ipv4_address: 172.30.0.9 + deploy: + resources: + limits: + cpus: "2.0" + memory: 2G + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + logging: *default-logging + + # Redis 172.30.0.10 + # ────────────────────────────────────────── + redis: + image: redis:7.2-alpine + container_name: trading-redis + <<: *restart + command: > + redis-server + --requirepass ${REDIS_PASSWORD} + --maxmemory ${REDIS_MAX_MEMORY:-2gb} + --maxmemory-policy ${REDIS_MAXMEMORY_POLICY:-allkeys-lru} + --save 900 1 + --save 300 10 + --appendonly yes + --appendfsync everysec + --tcp-keepalive 300 + ports: + - "6379:6379" + volumes: + - redis-data:/data + networks: + trading-net: + ipv4_address: 172.30.0.10 + deploy: + resources: + limits: + cpus: "2.0" + memory: 1G + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + logging: *default-logging + + # ────────────────────────────────────────── + # Qdrant 172.30.0.11 + # ────────────────────────────────────────── + qdrant: + image: qdrant/qdrant:v1.9.2 + container_name: trading-qdrant + <<: *restart + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant-data:/qdrant/storage + - ./qdrant-config/config.yaml:/qdrant/config/production.yaml:ro + environment: + QDRANT__SERVICE__HTTP_PORT: 6333 + QDRANT__SERVICE__GRPC_PORT: 6334 + QDRANT__LOG_LEVEL: INFO + networks: + trading-net: + ipv4_address: 172.30.0.11 + deploy: + resources: + limits: + cpus: "4.0" + memory: 512M + healthcheck: + test: ["CMD-SHELL", "true"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + logging: *default-logging + + # ────────────────────────────────────────── + # Bareun 형태소 엔진 172.30.0.15 + # ────────────────────────────────────────── + bareun: + image: bareunai/bareun:latest + container_name: trading-bareun + <<: *restart + ports: + - "5656:5656" + - "9902:9902" + volumes: + - bareun-data:/bareun/var + networks: + trading-net: + ipv4_address: 172.30.0.15 + deploy: + resources: + limits: + cpus: "4.0" + memory: 1500M + reservations: + memory: 512M + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:9902/health || exit 0"] + interval: 20s + timeout: 10s + retries: 5 + start_period: 30s + logging: *default-logging + + # ────────────────────────────────────────── + # 바른 API (FastAPI 래퍼) 172.30.0.12 + # ────────────────────────────────────────── + bareunaapi: + build: + context: ./bareunaapi + dockerfile: Dockerfile + container_name: trading-bareunaapi + <<: *restart + ports: + - "5757:5757" + environment: + BAREUN_API_KEY: "${BAREUN_API_KEY}" + BAREUN_SERVER_HOST: bareun + BAREUN_SERVER_PORT: 5656 + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_PASSWORD: "${REDIS_PASSWORD}" + REDIS_DB: 1 + NEWS_DEDUP_TTL: "${NEWS_DEDUP_TTL:-86400}" + LOG_LEVEL: INFO + networks: + trading-net: + ipv4_address: 172.30.0.12 + depends_on: + redis: + condition: service_healthy + bareun: + condition: service_started + deploy: + resources: + limits: + cpus: "4.0" + memory: 512M + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5757/health"] + interval: 15s + timeout: 10s + retries: 5 + start_period: 40s + logging: *default-logging + + # ────────────────────────────────────────── + # Ollama 추론+임베딩 (GPU 0+1) 172.30.0.13 + # GPU 0: RTX 3060 12GB → EXAONE 3.5 7.8B 추론 + # GPU 1: RTX 3070 8GB → bge-m3 임베딩 + # ────────────────────────────────────────── + ollama: + image: ollama/ollama:latest + container_name: trading-ollama + <<: *restart + ports: + - "11434:11434" + volumes: + - ollama-data:/root/.ollama + environment: + OLLAMA_NUM_PARALLEL: "${OLLAMA_NUM_PARALLEL:-4}" + OLLAMA_MAX_LOADED_MODELS: "2" + OLLAMA_FLASH_ATTENTION: "1" + CUDA_VISIBLE_DEVICES: "0,1" + networks: + trading-net: + ipv4_address: 172.30.0.13 + deploy: + resources: + limits: + cpus: "8.0" + memory: 24G + reservations: + devices: + - driver: nvidia + device_ids: ["0", "1"] + capabilities: [gpu] + healthcheck: + test: ["CMD-SHELL", "true"] + interval: 10s + timeout: 5s + retries: 3 + start_period: 10s + logging: *default-logging + + # ────────────────────────────────────────── + # n8n 워크플로우 172.30.0.20 + # ────────────────────────────────────────── + n8n: + image: n8nio/n8n:latest + container_name: trading-n8n + <<: *restart + ports: + - "5678:5678" + environment: + N8N_BASIC_AUTH_ACTIVE: "true" + N8N_BASIC_AUTH_USER: "${N8N_BASIC_AUTH_USER}" + N8N_BASIC_AUTH_PASSWORD: "${N8N_BASIC_AUTH_PASSWORD}" + N8N_ENCRYPTION_KEY: "${N8N_ENCRYPTION_KEY}" + WEBHOOK_URL: "${N8N_WEBHOOK_URL}" + N8N_LOG_LEVEL: info + N8N_METRICS: "true" + # DB - NAS PostgreSQL + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: "${POSTGRES_HOST}" + DB_POSTGRESDB_PORT: "${POSTGRES_PORT:-5432}" + DB_POSTGRESDB_DATABASE: "${POSTGRES_DB}" + DB_POSTGRESDB_USER: "${POSTGRES_USER}" + DB_POSTGRESDB_PASSWORD: "${POSTGRES_PASSWORD}" + DB_POSTGRESDB_SCHEMA: n8n + DB_POSTGRESDB_POOL_SIZE: "10" + DB_POSTGRESDB_CONNECT_TIMEOUT: "30000" + # Redis Queue + QUEUE_BULL_REDIS_HOST: redis + QUEUE_BULL_REDIS_PORT: 6379 + QUEUE_BULL_REDIS_PASSWORD: "${REDIS_PASSWORD}" + QUEUE_BULL_REDIS_DB: 0 + EXECUTIONS_MODE: queue + N8N_GRACEFUL_SHUTDOWN_TIMEOUT: 30 + TZ: "${TZ:-Asia/Seoul}" + GENERIC_TIMEZONE: "${TZ:-Asia/Seoul}" + # 내부 서비스 주소 (워크플로우에서 사용) + BAREUNAAPI_URL: "http://bareunaapi:5757" + OLLAMA_URL: "http://ollama:11434" + QDRANT_URL: "http://qdrant:6333" + REDIS_PASSWORD: "${REDIS_PASSWORD}" + POSTGRES_HOST: "${POSTGRES_HOST}" + POSTGRES_DB: "${POSTGRES_DB}" + POSTGRES_USER: "${POSTGRES_USER}" + POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" + NEWS_SIMILARITY_THRESHOLD: "${NEWS_SIMILARITY_THRESHOLD:-0.92}" + TELEGRAM_BOT_TOKEN: "${TELEGRAM_BOT_TOKEN:-}" + TELEGRAM_CHAT_ID: "${TELEGRAM_CHAT_ID:-}" + N8N_SECURE_COOKIE: "false" + # 실행 이력 자동 정리 (느린 n8n 해결) + EXECUTIONS_DATA_PRUNE: "true" + EXECUTIONS_DATA_MAX_AGE: 168 + EXECUTIONS_DATA_PRUNE_MAX_COUNT: 10000 + volumes: + - n8n-data:/home/node/.n8n + - /mnt/nas:/mnt/nas + networks: + trading-net: + ipv4_address: 172.30.0.20 + depends_on: + redis: + condition: service_healthy + qdrant: + condition: service_healthy + bareunaapi: + condition: service_healthy + postgres: + condition: service_healthy + deploy: + resources: + limits: + cpus: "4.0" + memory: 1G + healthcheck: + test: ["CMD", "wget", "-qO-", "http://localhost:5678/healthz"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: *default-logging + + # ────────────────────────────────────────── + # n8n Worker 172.30.0.21 + # ────────────────────────────────────────── + n8n-worker: + image: n8nio/n8n:latest + container_name: trading-n8n-worker + <<: *restart + command: worker + environment: + N8N_ENCRYPTION_KEY: "${N8N_ENCRYPTION_KEY}" + DB_TYPE: postgresdb + DB_POSTGRESDB_HOST: "${POSTGRES_HOST}" + DB_POSTGRESDB_PORT: "${POSTGRES_PORT:-5432}" + DB_POSTGRESDB_DATABASE: "${POSTGRES_DB}" + DB_POSTGRESDB_USER: "${POSTGRES_USER}" + DB_POSTGRESDB_PASSWORD: "${POSTGRES_PASSWORD}" + DB_POSTGRESDB_SCHEMA: n8n + DB_POSTGRESDB_POOL_SIZE: "5" + DB_POSTGRESDB_CONNECT_TIMEOUT: "30000" + QUEUE_BULL_REDIS_HOST: redis + QUEUE_BULL_REDIS_PORT: 6379 + QUEUE_BULL_REDIS_PASSWORD: "${REDIS_PASSWORD}" + QUEUE_BULL_REDIS_DB: 0 + EXECUTIONS_MODE: queue + TZ: "${TZ:-Asia/Seoul}" + GENERIC_TIMEZONE: "${TZ:-Asia/Seoul}" + BAREUNAAPI_URL: "http://bareunaapi:5757" + OLLAMA_URL: "http://ollama:11434" + QDRANT_URL: "http://qdrant:6333" + REDIS_PASSWORD: "${REDIS_PASSWORD}" + volumes: + - n8n-data:/home/node/.n8n + - /mnt/nas:/mnt/nas + networks: + trading-net: + ipv4_address: 172.30.0.21 + depends_on: + - n8n + deploy: + resources: + limits: + cpus: "4.0" + memory: 512M + logging: *default-logging diff --git a/graph-engine/Dockerfile b/graph-engine/Dockerfile new file mode 100644 index 0000000..89e5b03 --- /dev/null +++ b/graph-engine/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +# torch는 CPU 빌드만 받음 (Ollama가 GPU 점유, 추론은 CPU로 충분) +RUN pip install --no-cache-dir --default-timeout=300 --retries=5 \ + --index-url https://download.pytorch.org/whl/cpu torch==2.4.1 +RUN pip install --no-cache-dir --default-timeout=180 --retries=5 -r requirements.txt +COPY . . +EXPOSE 9090 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "9090", "--workers", "1", "--log-level", "info"] diff --git a/graph-engine/main.py b/graph-engine/main.py new file mode 100644 index 0000000..87472fa --- /dev/null +++ b/graph-engine/main.py @@ -0,0 +1,635 @@ +""" +Graph Engine (port 9090, 172.30.0.25) +한국 종목 그래프 신경망 (GAT) — 다음날 수익률 예측 → stock_scores.graph_score + +노드: 한국 활성종목 (dart_corps.is_active=true) +피처(12): 1d/5d/20d 수익률, vol_ratio, rsi, tech_score, + roe, operating_margin, debt_ratio, news_7d, us_overnight, log_mcap +엣지: ① 가격 60일 상관 |corr|>0.4 ② 같은 sector ③ 뉴스 공기 ≥3회 + +학습: 매주 일요일 06:00 (6mo rolling window) +추론: 매일 08:30 → stock_scores.graph_score +""" +import os +import asyncio +import json +import math +from datetime import date, datetime, timedelta +from typing import Optional, List, Tuple + +import asyncpg +import structlog +import numpy as np +import pandas as pd +import torch +import torch.nn as nn +import torch.nn.functional as F +from fastapi import FastAPI, Query, BackgroundTasks +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from pytz import timezone + +# ───────────────────────────────────────────────────────────── +# 설정 +# ───────────────────────────────────────────────────────────── +PG = { + "host": os.getenv("POSTGRES_HOST", "postgres"), + "port": int(os.getenv("POSTGRES_PORT", 5432)), + "database": os.getenv("POSTGRES_DB", "trading_ai"), + "user": os.getenv("POSTGRES_USER", "kyu"), + "password": os.getenv("POSTGRES_PASSWORD", ""), +} +KST = timezone("Asia/Seoul") +MODEL_DIR = os.getenv("GRAPH_MODEL_DIR", "/mnt/nas/models/graph") +os.makedirs(MODEL_DIR, exist_ok=True) +MODEL_PATH = os.path.join(MODEL_DIR, "gat_latest.pt") + +FEATURE_DIM = 12 +HIDDEN_DIM = 32 +ATT_HEADS = 4 +DROPOUT = 0.3 +CORR_LOOKBACK = 60 +CORR_THRESHOLD = 0.65 +TOP_K_NEIGHBORS = 20 # 노드당 가격 상관 엣지 상한 (메모리/속도 캡) +NEWS_LOOKBACK = 14 +NEWS_COOC_MIN = 3 +TRAIN_WINDOW_DAYS = 180 +SAMPLE_STRIDE_DAYS = 5 # 6개월 / 5일 = ~36 학습 샘플 + +logger = structlog.get_logger() +app = FastAPI(title="Graph Engine (GAT)") +pg_pool: Optional[asyncpg.Pool] = None +scheduler = AsyncIOScheduler(timezone=KST) +device = torch.device("cpu") + +DDL = """ +ALTER TABLE stock_scores + ADD COLUMN IF NOT EXISTS graph_score DOUBLE PRECISION; +CREATE TABLE IF NOT EXISTS graph_model_meta ( + model_date DATE PRIMARY KEY, + train_samples INT, + val_samples INT, + val_loss DOUBLE PRECISION, + edge_count INT, + node_count INT, + hidden_dim INT, + notes TEXT, + created_at TIMESTAMP DEFAULT NOW() +); +CREATE TABLE IF NOT EXISTS graph_predictions ( + stock_code VARCHAR(10), + predict_date DATE, + pred_return DOUBLE PRECISION, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (stock_code, predict_date) +); +""" + + +# ───────────────────────────────────────────────────────────── +# GAT 모델 (torch-geometric 없이 순수 torch) +# ───────────────────────────────────────────────────────────── +class GATLayer(nn.Module): + def __init__(self, in_dim, out_dim, heads, dropout): + super().__init__() + assert out_dim % heads == 0 + self.heads = heads + self.head_dim = out_dim // heads + self.W = nn.Linear(in_dim, out_dim, bias=False) + a_src = torch.empty(1, heads, self.head_dim) + a_dst = torch.empty(1, heads, self.head_dim) + nn.init.xavier_uniform_(a_src) + nn.init.xavier_uniform_(a_dst) + self.a_src = nn.Parameter(a_src.squeeze(0)) + self.a_dst = nn.Parameter(a_dst.squeeze(0)) + nn.init.xavier_uniform_(self.W.weight) + self.dropout = dropout + + def forward(self, x, edge_index, edge_weight=None): + N = x.size(0) + h = self.W(x).view(N, self.heads, self.head_dim) + src, dst = edge_index[0], edge_index[1] + # 어텐션 logits per (edge, head) + e_src = (h[src] * self.a_src.unsqueeze(0)).sum(-1) + e_dst = (h[dst] * self.a_dst.unsqueeze(0)).sum(-1) + e = F.leaky_relu(e_src + e_dst, 0.2) + if edge_weight is not None: + e = e + edge_weight.unsqueeze(-1).log().clamp(min=-10) + # dst별 softmax: max-subtract for stability + e_max = torch.full((N, self.heads), -1e9, device=x.device) + e_max = e_max.scatter_reduce(0, dst.unsqueeze(-1).expand(-1, self.heads), + e, reduce="amax", include_self=True) + e_exp = torch.exp(e - e_max[dst]) + denom = torch.zeros(N, self.heads, device=x.device).index_add_(0, dst, e_exp) + alpha = e_exp / (denom[dst] + 1e-12) + alpha = F.dropout(alpha, p=self.dropout, training=self.training) + # 메시지 집계 + m = h[src] * alpha.unsqueeze(-1) + out = torch.zeros(N, self.heads, self.head_dim, device=x.device) + out = out.index_add_(0, dst, m) + return out.reshape(N, -1) + + +class GraphNet(nn.Module): + def __init__(self, in_dim=FEATURE_DIM, hidden=HIDDEN_DIM, + heads=ATT_HEADS, dropout=DROPOUT): + super().__init__() + self.gat1 = GATLayer(in_dim, hidden, heads, dropout) + self.gat2 = GATLayer(hidden, hidden, heads, dropout) + self.head = nn.Linear(hidden, 1) + self.dropout = dropout + + def forward(self, x, edge_index, edge_weight=None): + x = F.elu(self.gat1(x, edge_index, edge_weight)) + x = F.dropout(x, p=self.dropout, training=self.training) + x = F.elu(self.gat2(x, edge_index, edge_weight)) + return self.head(x).squeeze(-1) + + +# ───────────────────────────────────────────────────────────── +# 데이터 로더 +# ───────────────────────────────────────────────────────────── +async def load_active_codes(conn) -> List[str]: + rows = await conn.fetch( + "SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code") + return [r["stock_code"] for r in rows] + + +async def load_features(conn, codes: List[str], target_date: date) -> np.ndarray: + """노드 피처 행렬 (N, 12) — 결측은 0으로.""" + N = len(codes) + F_ = FEATURE_DIM + out = np.zeros((N, F_), dtype=np.float32) + idx = {c: i for i, c in enumerate(codes)} + + # ── 가격 모멘텀 (1d/5d/20d 수익률), vol_ratio ── + rows = await conn.fetch(""" + SELECT stock_code, dt, close_price, volume + FROM stock_ohlcv + WHERE dt BETWEEN $1 AND $2 + ORDER BY stock_code, dt + """, target_date - timedelta(days=35), target_date) + df = pd.DataFrame(rows, columns=["stock_code", "dt", "close", "volume"]) + if not df.empty: + df["close"] = df["close"].astype(float) + df["volume"] = df["volume"].astype(float) + for code, g in df.groupby("stock_code"): + if code not in idx: + continue + g = g.sort_values("dt") + closes = g["close"].values + vols = g["volume"].values + if len(closes) >= 2: + out[idx[code], 0] = (closes[-1] / closes[-2] - 1) * 100 + if len(closes) >= 6: + out[idx[code], 1] = (closes[-1] / closes[-6] - 1) * 100 + if len(closes) >= 21: + out[idx[code], 2] = (closes[-1] / closes[-21] - 1) * 100 + if len(vols) >= 20: + v20 = vols[-20:].mean() + out[idx[code], 3] = (vols[-1] / v20) if v20 > 0 else 1.0 + + # ── 기술적 (RSI, tech_score) ── + rows = await conn.fetch(""" + SELECT DISTINCT ON (stock_code) stock_code, rsi, tech_score + FROM stock_technical + WHERE analyzed_at::date <= $1 + ORDER BY stock_code, analyzed_at DESC + """, target_date) + for r in rows: + if r["stock_code"] in idx: + out[idx[r["stock_code"]], 4] = float(r["rsi"] or 50) + out[idx[r["stock_code"]], 5] = float(r["tech_score"] or 0) + + # ── 펀더멘털 (ROE, op_margin, debt_ratio) ── + rows = await conn.fetch(""" + SELECT DISTINCT ON (stock_code) stock_code, roe, operating_margin, debt_ratio + FROM dart_financials + WHERE reprt_code='11011' + ORDER BY stock_code, bsns_year DESC + """) + for r in rows: + if r["stock_code"] in idx: + out[idx[r["stock_code"]], 6] = float(r["roe"] or 0) + out[idx[r["stock_code"]], 7] = float(r["operating_margin"] or 0) + out[idx[r["stock_code"]], 8] = float(r["debt_ratio"] or 0) + + # ── 뉴스 감성 7일 ── + rows = await conn.fetch(""" + SELECT primary_stock, + SUM(CASE sentiment WHEN '호재' THEN intensity + WHEN '악재' THEN -intensity ELSE 0 END)::float AS s + FROM news_analysis + WHERE analyzed_at BETWEEN $1 AND $2 + AND primary_stock IS NOT NULL + GROUP BY primary_stock + """, target_date - timedelta(days=7), target_date + timedelta(days=1)) + for r in rows: + if r["primary_stock"] in idx: + out[idx[r["primary_stock"]], 9] = float(r["s"]) + + # ── us_overnight_adj (latest) ── + rows = await conn.fetch(""" + SELECT DISTINCT ON (stock_code) stock_code, us_overnight_adj + FROM stock_scores + WHERE score_date <= $1 AND us_overnight_adj IS NOT NULL + ORDER BY stock_code, score_date DESC + """, target_date) + for r in rows: + if r["stock_code"] in idx: + out[idx[r["stock_code"]], 10] = float(r["us_overnight_adj"]) + + # ── log market cap ── + rows = await conn.fetch(""" + SELECT DISTINCT ON (stock_code) stock_code, market_cap + FROM stock_prices + WHERE market_cap IS NOT NULL + ORDER BY stock_code, collected_at DESC + """) + for r in rows: + if r["stock_code"] in idx and r["market_cap"]: + out[idx[r["stock_code"]], 11] = math.log10(float(r["market_cap"]) + 1) + + # 표준화 (피처별 z-score, 클리핑) + mu = out.mean(axis=0) + sd = out.std(axis=0) + 1e-6 + out = np.clip((out - mu) / sd, -5, 5) + return out.astype(np.float32) + + +async def load_price_corr_edges(conn, codes: List[str], target_date: date, + lookback: int = CORR_LOOKBACK, + threshold: float = CORR_THRESHOLD + ) -> Tuple[np.ndarray, np.ndarray]: + """가격 상관 엣지. 반환: edge_index (2, E), edge_weight (E,).""" + idx = {c: i for i, c in enumerate(codes)} + rows = await conn.fetch(""" + SELECT stock_code, dt, close_price FROM stock_ohlcv + WHERE dt BETWEEN $1 AND $2 AND stock_code = ANY($3) + ORDER BY stock_code, dt + """, target_date - timedelta(days=int(lookback * 1.6)), + target_date, codes) + if not rows: + return np.zeros((2, 0), dtype=np.int64), np.zeros(0, dtype=np.float32) + df = pd.DataFrame(rows, columns=["code", "dt", "close"]) + pivot = df.pivot(index="dt", columns="code", values="close").astype(float) + pivot = pivot.tail(lookback) + rets = pivot.pct_change().dropna(how="all") + # 결측치 많은 종목 제거 (절반 이상 결측) + rets = rets.dropna(axis=1, thresh=int(len(rets) * 0.5)) + if rets.shape[1] < 2: + return np.zeros((2, 0), dtype=np.int64), np.zeros(0, dtype=np.float32) + rets = rets.fillna(0) + M = rets.values # (T, K) + M = (M - M.mean(axis=0)) / (M.std(axis=0) + 1e-9) + K = M.shape[1] + corr = (M.T @ M) / max(M.shape[0] - 1, 1) + np.fill_diagonal(corr, 0) + abs_corr = np.abs(corr) + # 임계값 필터 + 노드당 top-K 이웃만 유지 (메모리 캡) + mask = abs_corr > threshold + src_set: List[int] = [] + dst_set: List[int] = [] + w_set: List[float] = [] + code_arr = list(rets.columns) + for i in range(K): + cands = np.where(mask[i])[0] + if len(cands) == 0: + continue + if len(cands) > TOP_K_NEIGHBORS: + top_idx = np.argpartition(-abs_corr[i, cands], TOP_K_NEIGHBORS)[:TOP_K_NEIGHBORS] + cands = cands[top_idx] + for j in cands: + src_set.append(idx[code_arr[i]]) + dst_set.append(idx[code_arr[j]]) + w_set.append(float(abs_corr[i, j])) + if not src_set: + return np.zeros((2, 0), dtype=np.int64), np.zeros(0, dtype=np.float32) + return (np.stack([np.array(src_set, dtype=np.int64), + np.array(dst_set, dtype=np.int64)]), + np.array(w_set, dtype=np.float32)) + + +async def load_sector_edges(conn, codes: List[str]) -> np.ndarray: + """같은 sector 노드 간 양방향 엣지.""" + idx = {c: i for i, c in enumerate(codes)} + rows = await conn.fetch(""" + SELECT stock_code, sector FROM dart_corps + WHERE is_active=true AND sector IS NOT NULL AND stock_code = ANY($1) + """, codes) + by_sec = {} + for r in rows: + by_sec.setdefault(r["sector"], []).append(r["stock_code"]) + src, dst = [], [] + for sec, lst in by_sec.items(): + # 섹터당 종목 수가 100개 초과면 비대해지므로 무작위 샘플 + if len(lst) > 80: + import random + random.seed(hash(sec) & 0xffff) + lst = random.sample(lst, 80) + for i, a in enumerate(lst): + for b in lst[i+1:]: + if a in idx and b in idx: + src += [idx[a], idx[b]] + dst += [idx[b], idx[a]] + if not src: + return np.zeros((2, 0), dtype=np.int64) + return np.stack([np.array(src, dtype=np.int64), np.array(dst, dtype=np.int64)]) + + +async def load_news_edges(conn, codes: List[str], target_date: date, + lookback_days: int = NEWS_LOOKBACK, + min_count: int = NEWS_COOC_MIN) -> np.ndarray: + """뉴스 공기관계: 같은 뉴스의 affected_stocks 페어 카운트 ≥ min_count.""" + idx = {c: i for i, c in enumerate(codes)} + rows = await conn.fetch(""" + SELECT affected_stocks FROM news_analysis + WHERE analyzed_at BETWEEN $1 AND $2 + AND jsonb_array_length(affected_stocks) > 1 + """, target_date - timedelta(days=lookback_days), + target_date + timedelta(days=1)) + from collections import Counter + counter: Counter = Counter() + for r in rows: + codes_in_news = r["affected_stocks"] + if isinstance(codes_in_news, str): + codes_in_news = json.loads(codes_in_news) + codes_in_news = [c for c in codes_in_news if c in idx] + for i, a in enumerate(codes_in_news): + for b in codes_in_news[i+1:]: + key = (a, b) if a < b else (b, a) + counter[key] += 1 + src, dst = [], [] + for (a, b), cnt in counter.items(): + if cnt >= min_count: + src += [idx[a], idx[b]] + dst += [idx[b], idx[a]] + if not src: + return np.zeros((2, 0), dtype=np.int64) + return np.stack([np.array(src, dtype=np.int64), np.array(dst, dtype=np.int64)]) + + +async def build_graph(conn, target_date: date): + """전체 그래프 구성. 반환: (codes, x, edge_index, edge_weight).""" + codes = await load_active_codes(conn) + x = await load_features(conn, codes, target_date) + ei_corr, ew_corr = await load_price_corr_edges(conn, codes, target_date) + ei_sec = await load_sector_edges(conn, codes) + ei_news = await load_news_edges(conn, codes, target_date) + # 결합 (sector/news는 weight=1.0) + ei_all = np.concatenate([ei_corr, ei_sec, ei_news], axis=1) + ew_all = np.concatenate([ + ew_corr, + np.full(ei_sec.shape[1], 0.5, dtype=np.float32), + np.full(ei_news.shape[1], 0.7, dtype=np.float32), + ]) + logger.info("graph.built", nodes=len(codes), edges=int(ei_all.shape[1]), + price=int(ei_corr.shape[1]), sector=int(ei_sec.shape[1]), + news=int(ei_news.shape[1])) + return codes, x, ei_all.astype(np.int64), ew_all.astype(np.float32) + + +async def load_labels(conn, codes: List[str], target_date: date) -> np.ndarray: + """target_date 종가 → target_date+1 영업일 종가의 변화율 (%).""" + idx = {c: i for i, c in enumerate(codes)} + labels = np.zeros(len(codes), dtype=np.float32) + mask = np.zeros(len(codes), dtype=bool) + rows = await conn.fetch(""" + SELECT stock_code, dt, close_price FROM stock_ohlcv + WHERE dt BETWEEN $1 AND $2 AND stock_code = ANY($3) + ORDER BY stock_code, dt + """, target_date, target_date + timedelta(days=7), codes) + by_code = {} + for r in rows: + by_code.setdefault(r["stock_code"], []).append( + (r["dt"], float(r["close_price"]))) + for code, lst in by_code.items(): + lst.sort() + if code in idx and len(lst) >= 2: + t0, c0 = lst[0] + t1, c1 = lst[1] + if c0 > 0: + labels[idx[code]] = (c1 / c0 - 1) * 100 + mask[idx[code]] = True + return labels, mask + + +# ───────────────────────────────────────────────────────────── +# 학습 +# ───────────────────────────────────────────────────────────── +async def train_model(window_days: int = TRAIN_WINDOW_DAYS, + stride: int = SAMPLE_STRIDE_DAYS, + epochs: int = 30, lr: float = 1e-3): + """rolling 윈도우 학습. 일별이 아닌 stride 간격으로 샘플링.""" + today = date.today() + sample_dates = [today - timedelta(days=window_days - i) + for i in range(0, window_days, stride)] + sample_dates = [d for d in sample_dates if d.weekday() < 5] + if len(sample_dates) < 8: + return {"err": "too few samples", "samples": len(sample_dates)} + + val_split = max(1, len(sample_dates) // 5) + train_dates = sample_dates[:-val_split] + val_dates = sample_dates[-val_split:] + logger.info("train.split", train=len(train_dates), val=len(val_dates)) + + async with pg_pool.acquire() as conn: + # 모든 샘플 그래프 미리 구성 + samples = [] + for d in sample_dates: + try: + codes, x, ei, ew = await build_graph(conn, d) + y, m = await load_labels(conn, codes, d) + if m.sum() < 10: + continue + samples.append({ + "date": d, + "x": torch.tensor(x), + "ei": torch.tensor(ei), + "ew": torch.tensor(ew), + "y": torch.tensor(y), + "m": torch.tensor(m), + }) + except Exception as e: + logger.warning("train.sample_err", date=str(d), err=str(e)) + + if len(samples) < 4: + return {"err": "no valid samples", "got": len(samples)} + + val_count = max(1, len(samples) // 5) + train_set = samples[:-val_count] + val_set = samples[-val_count:] + + model = GraphNet().to(device) + opt = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-5) + best_val = float("inf") + + for epoch in range(epochs): + model.train() + tot = 0.0 + for s in train_set: + opt.zero_grad() + pred = model(s["x"].to(device), s["ei"].to(device), + s["ew"].to(device)) + mask = s["m"].to(device) + loss = F.huber_loss(pred[mask], s["y"].to(device)[mask], delta=2.0) + loss.backward() + torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0) + opt.step() + tot += float(loss) + tot /= len(train_set) + + model.eval() + v = 0.0 + with torch.no_grad(): + for s in val_set: + pred = model(s["x"].to(device), s["ei"].to(device), + s["ew"].to(device)) + mask = s["m"].to(device) + v += float(F.huber_loss(pred[mask], s["y"].to(device)[mask], + delta=2.0)) + v /= len(val_set) + logger.info("train.epoch", epoch=epoch, train_loss=tot, val_loss=v) + if v < best_val: + best_val = v + torch.save({"state": model.state_dict(), + "feature_dim": FEATURE_DIM, + "hidden": HIDDEN_DIM, + "heads": ATT_HEADS}, MODEL_PATH) + + async with pg_pool.acquire() as conn: + await conn.execute(""" + INSERT INTO graph_model_meta + (model_date, train_samples, val_samples, val_loss, + edge_count, node_count, hidden_dim, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (model_date) DO UPDATE + SET train_samples=$2, val_samples=$3, val_loss=$4, + edge_count=$5, node_count=$6, hidden_dim=$7, notes=$8 + """, today, len(train_set), len(val_set), best_val, + int(samples[-1]["ei"].shape[1]), int(samples[-1]["x"].shape[0]), + HIDDEN_DIM, f"epochs={epochs} lr={lr} stride={stride}") + return {"trained": True, "train_samples": len(train_set), + "val_samples": len(val_set), "best_val_loss": best_val} + + +# ───────────────────────────────────────────────────────────── +# 추론 +# ───────────────────────────────────────────────────────────── +async def predict_today() -> dict: + if not os.path.exists(MODEL_PATH): + return {"err": "no trained model"} + today = date.today() + async with pg_pool.acquire() as conn: + codes, x, ei, ew = await build_graph(conn, today) + model = GraphNet().to(device) + ckpt = torch.load(MODEL_PATH, map_location=device, weights_only=True) + model.load_state_dict(ckpt["state"]) + model.eval() + with torch.no_grad(): + pred = model(torch.tensor(x).to(device), + torch.tensor(ei).to(device), + torch.tensor(ew).to(device)) + pred_np = pred.cpu().numpy() + # graph_predictions만 저장 — score-engine이 calculate_daily_scores 시 읽어 ensemble에 적용. + async with pg_pool.acquire() as conn: + async with conn.transaction(): + for i, code in enumerate(codes): + p = float(pred_np[i]) + await conn.execute(""" + INSERT INTO graph_predictions (stock_code, predict_date, pred_return) + VALUES ($1, $2, $3) + ON CONFLICT (stock_code, predict_date) DO UPDATE + SET pred_return=$3, created_at=NOW() + """, code, today, p) + return {"predicted": len(codes), "date": str(today)} + + +# ───────────────────────────────────────────────────────────── +# FastAPI +# ───────────────────────────────────────────────────────────── +@app.on_event("startup") +async def on_start(): + global pg_pool + pg_pool = await asyncpg.create_pool(**PG, min_size=2, max_size=5) + async with pg_pool.acquire() as conn: + await conn.execute(DDL) + # 16:25 KST: score-engine 16:30 calculate_daily_scores 직전 추론 + scheduler.add_job(predict_today, CronTrigger(hour=16, minute=25, + day_of_week="mon-fri"), + id="graph_predict", replace_existing=True) + scheduler.add_job(train_model, CronTrigger(day_of_week="sun", hour=6, + minute=0), + id="graph_train", replace_existing=True) + scheduler.start() + logger.info("graph-engine.started") + + +@app.on_event("shutdown") +async def on_stop(): + if scheduler.running: + scheduler.shutdown() + if pg_pool: + await pg_pool.close() + + +@app.get("/health") +async def health(): + return {"status": "ok", + "model_exists": os.path.exists(MODEL_PATH)} + + +@app.post("/graph/build") +async def manual_build(target: Optional[str] = None): + d = date.fromisoformat(target) if target else date.today() + async with pg_pool.acquire() as conn: + codes, x, ei, ew = await build_graph(conn, d) + return {"nodes": len(codes), "edges": int(ei.shape[1]), + "feature_shape": list(x.shape), "date": str(d)} + + +@app.post("/train") +async def manual_train(epochs: int = Query(default=30, ge=1, le=200), + window_days: int = Query(default=180, ge=30, le=365), + stride: int = Query(default=5, ge=1, le=30), + bg: BackgroundTasks = None): + if bg: + bg.add_task(train_model, window_days, stride, epochs) + return {"status": "queued", "epochs": epochs} + return await train_model(window_days, stride, epochs) + + +@app.post("/predict") +async def manual_predict(): + return await predict_today() + + +@app.get("/predict/{code}") +async def predict_stock(code: str, limit: int = Query(default=30, ge=1, le=180)): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT predict_date, pred_return + FROM graph_predictions + WHERE stock_code=$1 + ORDER BY predict_date DESC LIMIT $2 + """, code, limit) + return {"code": code, "history": [dict(r) for r in rows]} + + +@app.get("/status") +async def status(): + async with pg_pool.acquire() as conn: + meta = await conn.fetchrow(""" + SELECT * FROM graph_model_meta ORDER BY model_date DESC LIMIT 1 + """) + latest_pred = await conn.fetchrow(""" + SELECT predict_date, COUNT(*) AS n + FROM graph_predictions + GROUP BY predict_date + ORDER BY predict_date DESC LIMIT 1 + """) + return { + "model": dict(meta) if meta else None, + "latest_prediction": dict(latest_pred) if latest_pred else None, + "model_exists": os.path.exists(MODEL_PATH), + } diff --git a/graph-engine/requirements.txt b/graph-engine/requirements.txt new file mode 100644 index 0000000..94725b6 --- /dev/null +++ b/graph-engine/requirements.txt @@ -0,0 +1,12 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +asyncpg==0.29.0 +apscheduler==3.10.4 +structlog==24.2.0 +orjson==3.10.3 +httpx==0.27.0 +pandas==2.2.2 +numpy==1.26.4 +scipy==1.13.1 +pytz==2024.1 +torch==2.4.1 diff --git a/init-db.sql b/init-db.sql new file mode 100644 index 0000000..030e4d8 --- /dev/null +++ b/init-db.sql @@ -0,0 +1,150 @@ +-- ============================================================ +-- Trading AI - PostgreSQL 테이블 초기화 +-- NAS (192.168.0.36:32770) 에서 실행 +-- ============================================================ + +-- 뉴스 분석 결과 테이블 +CREATE TABLE IF NOT EXISTS news_analysis ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + url TEXT DEFAULT '', + source VARCHAR(100) DEFAULT '', + published_at TIMESTAMP WITH TIME ZONE, + hash VARCHAR(16) UNIQUE NOT NULL, + + -- AI 분석 결과 + sentiment VARCHAR(10) DEFAULT '중립', -- 호재/악재/중립 + intensity INTEGER DEFAULT 0, -- 1~5 + primary_stock VARCHAR(10) DEFAULT '', -- 주요 종목코드 + affected_stocks JSONB DEFAULT '[]'::jsonb, -- 영향 종목 목록 + reason TEXT DEFAULT '', -- 판단 근거 + investment_action VARCHAR(20) DEFAULT '관망', -- 매수관심/매도관심/관망 + + -- 형태소 분석 결과 + keywords JSONB DEFAULT '[]'::jsonb, + stock_names JSONB DEFAULT '[]'::jsonb, + stock_codes JSONB DEFAULT '[]'::jsonb, + + -- 메타데이터 + similar_count INTEGER DEFAULT 0, + analyzed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- 인덱스 +CREATE INDEX IF NOT EXISTS idx_news_hash ON news_analysis(hash); +CREATE INDEX IF NOT EXISTS idx_news_sentiment ON news_analysis(sentiment); +CREATE INDEX IF NOT EXISTS idx_news_primary_stock ON news_analysis(primary_stock); +CREATE INDEX IF NOT EXISTS idx_news_published_at ON news_analysis(published_at DESC); +CREATE INDEX IF NOT EXISTS idx_news_analyzed_at ON news_analysis(analyzed_at DESC); +CREATE INDEX IF NOT EXISTS idx_news_intensity ON news_analysis(intensity DESC); +CREATE INDEX IF NOT EXISTS idx_news_investment_action ON news_analysis(investment_action); + +-- 종목별 빠른 조회용 GIN 인덱스 +CREATE INDEX IF NOT EXISTS idx_news_affected_stocks ON news_analysis USING GIN (affected_stocks); +CREATE INDEX IF NOT EXISTS idx_news_stock_codes ON news_analysis USING GIN (stock_codes); + +-- 유용한 뷰: 최근 호재/악재 요약 +CREATE OR REPLACE VIEW v_recent_signals AS +SELECT + sentiment, + intensity, + title, + primary_stock, + affected_stocks, + investment_action, + reason, + published_at, + analyzed_at +FROM news_analysis +WHERE sentiment IN ('호재', '악재') + AND analyzed_at >= NOW() - INTERVAL '24 hours' +ORDER BY intensity DESC, analyzed_at DESC; + +-- 기술적 분석 결과 테이블 +CREATE TABLE IF NOT EXISTS stock_technical ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) UNIQUE NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + price INTEGER DEFAULT 0, + ma5 FLOAT DEFAULT 0, + ma20 FLOAT DEFAULT 0, + ma60 FLOAT DEFAULT 0, + ma120 FLOAT DEFAULT 0, + rsi FLOAT DEFAULT 50, + macd FLOAT DEFAULT 0, + macd_signal FLOAT DEFAULT 0, + macd_hist FLOAT DEFAULT 0, + bb_upper FLOAT DEFAULT 0, + bb_mid FLOAT DEFAULT 0, + bb_lower FLOAT DEFAULT 0, + pct_b FLOAT DEFAULT 0.5, + stoch_k FLOAT DEFAULT 50, + stoch_d FLOAT DEFAULT 50, + vol_ratio FLOAT DEFAULT 1, + tech_score FLOAT DEFAULT 0, + signal VARCHAR(10) DEFAULT '관망', + signals JSONB DEFAULT '[]'::jsonb, + targets JSONB DEFAULT '{}'::jsonb, + analyzed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_ta_score ON stock_technical(tech_score DESC); +CREATE INDEX IF NOT EXISTS idx_ta_signal ON stock_technical(signal); +CREATE INDEX IF NOT EXISTS idx_ta_code ON stock_technical(stock_code); + +-- 유용한 뷰: 종목별 뉴스 카운트 +CREATE OR REPLACE VIEW v_stock_news_count AS +SELECT + primary_stock, + sentiment, + COUNT(*) as count, + AVG(intensity) as avg_intensity, + MAX(analyzed_at) as latest_at +FROM news_analysis +WHERE primary_stock != '' + AND analyzed_at >= NOW() - INTERVAL '7 days' +GROUP BY primary_stock, sentiment +ORDER BY count DESC; + +-- DART 기업 정보 테이블 +CREATE TABLE IF NOT EXISTS dart_corps ( + stock_code VARCHAR(10) PRIMARY KEY, + corp_code VARCHAR(20) NOT NULL, + corp_name VARCHAR(200) NOT NULL, + modify_date VARCHAR(20) DEFAULT '', + is_active BOOLEAN DEFAULT false +); + +CREATE INDEX IF NOT EXISTS idx_dart_corps_active ON dart_corps(is_active); +CREATE INDEX IF NOT EXISTS idx_dart_corps_corp_code ON dart_corps(corp_code); + +-- DART 재무제표 테이블 (버핏 가치지표 포함) +CREATE TABLE IF NOT EXISTS dart_financials ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + corp_code VARCHAR(20), + corp_name VARCHAR(200), + bsns_year VARCHAR(4) NOT NULL, + reprt_code VARCHAR(10) NOT NULL, + reprt_name VARCHAR(50), + revenue BIGINT DEFAULT 0, + operating_profit BIGINT DEFAULT 0, + net_income BIGINT DEFAULT 0, + total_assets BIGINT DEFAULT 0, + total_liabilities BIGINT DEFAULT 0, + total_equity BIGINT DEFAULT 0, + operating_cashflow BIGINT DEFAULT 0, + roe FLOAT DEFAULT 0, + operating_margin FLOAT DEFAULT 0, + net_margin FLOAT DEFAULT 0, + debt_ratio FLOAT DEFAULT 0, + revenue_growth FLOAT DEFAULT 0, + fcf_ratio FLOAT DEFAULT 0, + collected_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, bsns_year, reprt_code) +); + +CREATE INDEX IF NOT EXISTS idx_fin_stock ON dart_financials(stock_code); +CREATE INDEX IF NOT EXISTS idx_fin_year ON dart_financials(bsns_year DESC); +CREATE INDEX IF NOT EXISTS idx_fin_roe ON dart_financials(roe DESC); diff --git a/kis-api-main.py b/kis-api-main.py new file mode 100644 index 0000000..df3f8a6 --- /dev/null +++ b/kis-api-main.py @@ -0,0 +1,235 @@ +""" +주가 수집 + 매매 시그널 서비스 (네이버 금융 기반) +- API 키 불필요 +- 시총 상위 200개 종목 현재가 +- 매수/매도 목표가 + 손절가 자동 계산 +""" +import asyncio,json,os,re +from datetime import datetime,timedelta +from typing import Optional +import asyncpg,httpx,redis.asyncio as aioredis,structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI,Query +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware + +structlog.configure(processors=[structlog.processors.TimeStamper(fmt="iso"),structlog.processors.add_log_level,structlog.processors.JSONRenderer()]) +logger=structlog.get_logger() +REDIS_HOST=os.getenv("REDIS_HOST","redis") +REDIS_PASSWORD=os.getenv("REDIS_PASSWORD","") +PG_HOST=os.getenv("POSTGRES_HOST","postgres") +PG_PORT=int(os.getenv("POSTGRES_PORT","5432")) +PG_DB=os.getenv("POSTGRES_DB","trading_ai") +PG_USER=os.getenv("POSTGRES_USER","kyu") +PG_PASS=os.getenv("POSTGRES_PASSWORD","7895123") +HEADERS={"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + +pg_pool:Optional[asyncpg.Pool]=None +redis_cl:Optional[aioredis.Redis]=None +scheduler=AsyncIOScheduler(timezone="Asia/Seoul") + +class Stats: + collected=0;last_run="";errors=0 +stats=Stats() + +async def init_db(): + async with pg_pool.acquire() as c: + await c.execute("""CREATE TABLE IF NOT EXISTS stock_prices( + id SERIAL PRIMARY KEY,stock_code VARCHAR(10) NOT NULL,stock_name VARCHAR(100) DEFAULT '', + price INTEGER DEFAULT 0,change_pct FLOAT DEFAULT 0,change_amount INTEGER DEFAULT 0, + volume BIGINT DEFAULT 0,high INTEGER DEFAULT 0,low INTEGER DEFAULT 0,open_price INTEGER DEFAULT 0, + market_cap BIGINT DEFAULT 0,per FLOAT DEFAULT 0,pbr FLOAT DEFAULT 0, + high_52w INTEGER DEFAULT 0,low_52w INTEGER DEFAULT 0,collected_at TIMESTAMP DEFAULT NOW())""") + await c.execute("CREATE INDEX IF NOT EXISTS idx_sp_code ON stock_prices(stock_code)") + await c.execute("CREATE INDEX IF NOT EXISTS idx_sp_time ON stock_prices(collected_at DESC)") + await c.execute("""CREATE TABLE IF NOT EXISTS trade_signals( + id SERIAL PRIMARY KEY,stock_code VARCHAR(10) NOT NULL,stock_name VARCHAR(100) DEFAULT '', + signal_type VARCHAR(10) NOT NULL,current_price INTEGER DEFAULT 0,target_price INTEGER DEFAULT 0, + stop_loss INTEGER DEFAULT 0,expected_return_pct FLOAT DEFAULT 0,risk_reward_ratio FLOAT DEFAULT 0, + confidence FLOAT DEFAULT 0,reason TEXT DEFAULT '',news_score FLOAT DEFAULT 0, + dart_score FLOAT DEFAULT 0,price_momentum FLOAT DEFAULT 0,created_at TIMESTAMP DEFAULT NOW())""") + await c.execute("CREATE INDEX IF NOT EXISTS idx_ts_code ON trade_signals(stock_code)") + await c.execute("CREATE INDEX IF NOT EXISTS idx_ts_time ON trade_signals(created_at DESC)") + logger.info("db.ok") + +async def naver_price(client,code): + try: + r=await client.get(f"https://finance.naver.com/item/main.naver?code={code}",headers=HEADERS,timeout=15) + r.encoding="euc-kr";h=r.text + def ex(p,d="0"): + m=re.search(p,h);return m.group(1).replace(",","").strip() if m else d + name=ex(r'class="wrap_company".*?

]*>([^<]+)',"") + price=int(ex(r'

.*?([0-9,]+)')) + cpct=ex(r'class="blind">([0-9.\-+]+)%',"0") + vol=ex(r'.*?([0-9,]+)') + hi=ex(r'최고.*?([0-9,]+)') + lo=ex(r'최저.*?([0-9,]+)') + cap=ex(r'시가총액.*?([0-9,]+)',"0") + per=ex(r'PER.*?([0-9.]+)',"0") + pbr=ex(r'PBR.*?([0-9.]+)',"0") + h52=ex(r'52주.*?최고.*?([0-9,]+)',"0") + l52=ex(r'52주.*?최저.*?([0-9,]+)',"0") + return {"code":code,"name":name,"price":price,"change_pct":float(cpct), + "volume":int(vol),"high":int(hi),"low":int(lo),"market_cap":int(cap), + "per":float(per),"pbr":float(pbr),"high_52w":int(h52),"low_52w":int(l52), + "timestamp":datetime.now().isoformat()} + except Exception as e: + return None + +async def get_stock_codes(client,count=200): + if pg_pool: + try: + rows=await pg_pool.fetch("SELECT stock_code FROM dart_corps LIMIT $1",count) + codes=[r["stock_code"] for r in rows if r["stock_code"]]; + if len(codes)>50:return codes[:count] + except:pass + codes=[] + for sosok in [0,1]: + for page in range(1,20): + try: + r=await client.get(f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}",headers=HEADERS,timeout=15) + r.encoding="euc-kr" + found=re.findall(r'main\.naver\?code=(\d{6})',r.text) + if not found:break + codes.extend(found);await asyncio.sleep(0.2) + except:break + if len(codes)>=count:break + return list(dict.fromkeys(codes))[:count] + +def calc_signal(p,news_sc,dart_sc): + price=p["price"] + if price<=0:return None + h52=p.get("high_52w",0);l52=p.get("low_52w",0);cpct=p.get("change_pct",0) + total=news_sc*0.4+dart_sc*0.3+(cpct*10)*0.3 + total=max(-100,min(100,total)) + pos=(price-l52)/(h52-l52) if h52>l52>0 else 0.5 + + if total>=30: + sig="매수" + tp=int(price+(h52-price)*(0.3+min(total,100)/200)) if h52>price else int(price*1.1) + sl=int(price*(0.92+pos*0.03)) + conf=min(95,50+total*0.3+(1-pos)*20) + elif total<=-30: + sig="매도" + tp=int(price-(price-l52)*(0.3+min(abs(total),100)/200)) if l52>0 and l520 else 0 + return {"signal_type":sig,"current_price":price,"target_price":tp,"stop_loss":sl, + "expected_return_pct":round(er,2),"risk_reward_ratio":round(rr,2), + "confidence":round(conf,1),"price_momentum":round(cpct,2), + "news_score":round(news_sc,1),"dart_score":round(dart_sc,1)} + +async def collect_prices(): + logger.info("prices.start") + async with httpx.AsyncClient() as client: + codes=await get_stock_codes(client,200) + ok=0 + for code in codes: + p=await naver_price(client,code) + if not p or p["price"]<=0:continue + if redis_cl:await redis_cl.set(f"price:{code}",json.dumps(p,ensure_ascii=False),ex=600) + if pg_pool: + try: + await pg_pool.execute("""INSERT INTO stock_prices(stock_code,stock_name,price,change_pct, + volume,high,low,open_price,market_cap,per,pbr,high_52w,low_52w,collected_at) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)""", + code,p["name"],p["price"],p["change_pct"],p["volume"],p["high"],p["low"], + 0,p["market_cap"],p["per"],p["pbr"],p["high_52w"],p["low_52w"],datetime.now()) + except:pass + ok+=1;await asyncio.sleep(0.3) + await gen_signals() + stats.collected+=ok;stats.last_run=datetime.now().isoformat() + if redis_cl:await redis_cl.set("prices:last_update",datetime.now().isoformat()) + logger.info("prices.done",count=ok) + +async def gen_signals(): + if not pg_pool:return + async with pg_pool.acquire() as c: + scores=await c.fetch("""SELECT stock_code,stock_name,news_score,dart_score,total_score + FROM stock_scores WHERE score_date=(SELECT MAX(score_date) FROM stock_scores) + AND (total_score>=30 OR total_score<=-30)""") + n=0 + for row in scores: + code=row["stock_code"] + if not code or len(code)!=6:continue + if redis_cl: + cached=await redis_cl.get(f"price:{code}") + if not cached:continue + pd=json.loads(cached) + else:continue + sig=calc_signal(pd,row["news_score"],row["dart_score"]) + if not sig:continue + reasons=await c.fetch("SELECT reason FROM news_analysis WHERE primary_stock=$1 AND intensity>=2 ORDER BY analyzed_at DESC LIMIT 2",code) + reason=" | ".join([r["reason"][:100] for r in reasons]) + await c.execute("""INSERT INTO trade_signals(stock_code,stock_name,signal_type,current_price, + target_price,stop_loss,expected_return_pct,risk_reward_ratio,confidence,reason, + news_score,dart_score,price_momentum) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)""", + code,row["stock_name"],sig["signal_type"],sig["current_price"],sig["target_price"], + sig["stop_loss"],sig["expected_return_pct"],sig["risk_reward_ratio"],sig["confidence"], + reason,sig["news_score"],sig["dart_score"],sig["price_momentum"]) + n+=1 + logger.info("signals.done",count=n) + +app=FastAPI(title="주가+시그널") +app.add_middleware(CORSMiddleware,allow_origins=["*"],allow_methods=["*"],allow_headers=["*"]) + +@app.on_event("startup") +async def startup(): + global pg_pool,redis_cl + pg_pool=await asyncpg.create_pool(host=PG_HOST,port=PG_PORT,database=PG_DB,user=PG_USER,password=PG_PASS,min_size=2,max_size=5) + redis_cl=aioredis.Redis(host=REDIS_HOST,port=6379,password=REDIS_PASSWORD,db=3,decode_responses=True) + await init_db() + scheduler.add_job(collect_prices,"cron",day_of_week="mon-fri",hour="9-16",minute="*/5",id="prices",replace_existing=True) + scheduler.start();logger.info("started") + +@app.on_event("shutdown") +async def shutdown(): + scheduler.shutdown() + if pg_pool:await pg_pool.close() + if redis_cl:await redis_cl.aclose() + +@app.get("/health") +async def health(): + lu=await redis_cl.get("prices:last_update") if redis_cl else "" + return JSONResponse(content={"status":"ok","collected":stats.collected,"last_run":stats.last_run,"last_update":lu or ""}) + +@app.get("/price/{code}") +async def price(code:str): + if redis_cl: + c=await redis_cl.get(f"price:{code}") + if c:return JSONResponse(content=json.loads(c)) + async with httpx.AsyncClient() as cl: + p=await naver_price(cl,code) + if p:return JSONResponse(content=p) + return JSONResponse(content={"error":"not found"},status_code=404) + +@app.get("/prices") +async def prices(limit:int=Query(default=50)): + if not redis_cl:return JSONResponse(content={"data":{}}) + keys=await redis_cl.keys("price:*") + r={}; + for k in keys[:limit]: + c=await redis_cl.get(k) + if c:d=json.loads(c);r[d["code"]]=d + return JSONResponse(content={"count":len(r),"data":r}) + +@app.get("/signals") +async def signals(days:int=Query(default=7)): + async with pg_pool.acquire() as c: + rows=await c.fetch("SELECT * FROM trade_signals WHERE created_at>=NOW()-INTERVAL '%s days' ORDER BY confidence DESC LIMIT 30"%days) + return [dict(r) for r in rows] + +@app.get("/signals/{code}") +async def stock_signals(code:str): + async with pg_pool.acquire() as c: + rows=await c.fetch("SELECT * FROM trade_signals WHERE stock_code=$1 ORDER BY created_at DESC LIMIT 10",code) + return [dict(r) for r in rows] + +@app.post("/collect") +async def manual(): + asyncio.create_task(collect_prices());return {"status":"started"} diff --git a/kis-api/Dockerfile b/kis-api/Dockerfile new file mode 100644 index 0000000..e4360c9 --- /dev/null +++ b/kis-api/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8585 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8585", "--workers", "1", "--log-level", "info"] diff --git a/kis-api/main.py b/kis-api/main.py new file mode 100644 index 0000000..c69625b --- /dev/null +++ b/kis-api/main.py @@ -0,0 +1,902 @@ +""" +키움증권 REST API 기반 주가·수급·공매도 수집 서비스 +- ka10001: 현재가 + 재무지표 (PER/PBR/ROE/EPS/BPS/외국인비중/시가총액) +- ka10005: 일봉 OHLCV + 외국인·기관 순매수 +- ka10008: 외국인 종목별 매매동향 (일자별 보유비중 변화) +- ka10014: 공매도 추이 (잔고수량·거래비중) +- Redis db=3 price:{code} 형식 유지 (score-engine·ta-engine 호환) +""" +import asyncio, json, os, re +from datetime import datetime, timedelta +from typing import Optional +import asyncpg, httpx, redis.asyncio as aioredis, structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI, Query +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +PG_HOST = os.getenv("POSTGRES_HOST", "postgres") +PG_PORT = int(os.getenv("POSTGRES_PORT", "5432")) +PG_DB = os.getenv("POSTGRES_DB", "trading_ai") +PG_USER = os.getenv("POSTGRES_USER", "kyu") +PG_PASS = os.getenv("POSTGRES_PASSWORD", "") +KIWOOM_APP_KEY = os.getenv("KIWOOM_APP_KEY", "") +KIWOOM_SECRET_KEY = os.getenv("KIWOOM_SECRET_KEY", "") +KIWOOM_BASE_URL = os.getenv("KIWOOM_BASE_URL", "https://api.kiwoom.com") + +pg_pool: Optional[asyncpg.Pool] = None +redis_cl: Optional[aioredis.Redis] = None +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + +# ── 키움 토큰 관리 ──────────────────────────────────────── + +class KiwoomToken: + token: str = "" + expires_at: datetime = datetime.min + + async def get(self, client: httpx.AsyncClient) -> str: + if self.token and datetime.now() < self.expires_at: + return self.token + resp = await client.post( + f"{KIWOOM_BASE_URL}/oauth2/token", + json={"grant_type": "client_credentials", + "appkey": KIWOOM_APP_KEY, + "secretkey": KIWOOM_SECRET_KEY}, + headers={"Content-Type": "application/json;charset=UTF-8"}, + timeout=10) + data = resp.json() + if data.get("return_code", -1) != 0: + raise RuntimeError(f"토큰 발급 실패: {data.get('return_msg')}") + self.token = data["token"] + # expires_dt: "20260508151325" 형식 + exp_str = data.get("expires_dt", "") + try: + self.expires_at = datetime.strptime(exp_str, "%Y%m%d%H%M%S") - timedelta(minutes=5) + except Exception: + self.expires_at = datetime.now() + timedelta(hours=23) + logger.info("kiwoom.token.refreshed", expires=exp_str) + return self.token + +kiwoom_token = KiwoomToken() + +# ── 키움 API 호출 헬퍼 ──────────────────────────────────── + +async def kiwoom_post(client: httpx.AsyncClient, endpoint: str, api_id: str, + body: dict, cont_yn: str = "N", next_key: str = "", + return_headers: bool = False): + token = await kiwoom_token.get(client) + headers = { + "Content-Type": "application/json;charset=UTF-8", + "Authorization": f"Bearer {token}", + "api-id": api_id, + "cont-yn": cont_yn, + } + if next_key: + headers["next-key"] = next_key + r = await client.post(f"{KIWOOM_BASE_URL}{endpoint}", + headers=headers, json=body, timeout=15) + if return_headers: # 연속조회용 cont-yn/next-key 응답헤더 필요 + return r.json(), r.headers + return r.json() + +def to_int(v: str) -> int: + try: + return int(str(v).replace(",", "").lstrip("+").replace(" ", "") or 0) + except: + return 0 + +def to_float(v: str) -> float: + try: + return float(str(v).replace(",", "").lstrip("+").replace(" ", "") or 0) + except: + return 0.0 + +# ── DB 초기화 ───────────────────────────────────────────── + +async def init_db(): + async with pg_pool.acquire() as c: + await c.execute(""" + CREATE TABLE IF NOT EXISTS stock_prices ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + price INTEGER DEFAULT 0, + change_pct FLOAT DEFAULT 0, + change_amount INTEGER DEFAULT 0, + volume BIGINT DEFAULT 0, + high INTEGER DEFAULT 0, + low INTEGER DEFAULT 0, + open_price INTEGER DEFAULT 0, + market_cap BIGINT DEFAULT 0, + per FLOAT DEFAULT 0, + pbr FLOAT DEFAULT 0, + eps FLOAT DEFAULT 0, + bps FLOAT DEFAULT 0, + roe FLOAT DEFAULT 0, + ev FLOAT DEFAULT 0, + high_52w INTEGER DEFAULT 0, + low_52w INTEGER DEFAULT 0, + foreign_ratio FLOAT DEFAULT 0, + credit_ratio FLOAT DEFAULT 0, + float_shares BIGINT DEFAULT 0, + collected_at TIMESTAMP DEFAULT NOW() + )""") + await c.execute("CREATE INDEX IF NOT EXISTS idx_sp_code ON stock_prices(stock_code)") + await c.execute("CREATE INDEX IF NOT EXISTS idx_sp_time ON stock_prices(collected_at DESC)") + + await c.execute(""" + CREATE TABLE IF NOT EXISTS stock_ohlcv ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + dt DATE NOT NULL, + open_price INTEGER DEFAULT 0, + high_price INTEGER DEFAULT 0, + low_price INTEGER DEFAULT 0, + close_price INTEGER DEFAULT 0, + volume BIGINT DEFAULT 0, + trade_amount BIGINT DEFAULT 0, + foreign_ratio FLOAT DEFAULT 0, + foreign_net BIGINT DEFAULT 0, + institution_net BIGINT DEFAULT 0, + individual_net BIGINT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, dt) + )""") + await c.execute("CREATE INDEX IF NOT EXISTS idx_ohlcv_code_dt ON stock_ohlcv(stock_code, dt DESC)") + + await c.execute(""" + CREATE TABLE IF NOT EXISTS stock_foreign_flow ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + dt DATE NOT NULL, + close_price INTEGER DEFAULT 0, + change_qty BIGINT DEFAULT 0, + hold_qty BIGINT DEFAULT 0, + hold_ratio FLOAT DEFAULT 0, + limit_ratio FLOAT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, dt) + )""") + await c.execute("CREATE INDEX IF NOT EXISTS idx_ff_code_dt ON stock_foreign_flow(stock_code, dt DESC)") + + await c.execute(""" + CREATE TABLE IF NOT EXISTS stock_short_sale ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + dt DATE NOT NULL, + close_price INTEGER DEFAULT 0, + short_qty BIGINT DEFAULT 0, + short_balance_qty BIGINT DEFAULT 0, + trade_weight FLOAT DEFAULT 0, + short_avg_price INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, dt) + )""") + await c.execute("CREATE INDEX IF NOT EXISTS idx_ss_code_dt ON stock_short_sale(stock_code, dt DESC)") + + await c.execute(""" + CREATE TABLE IF NOT EXISTS trade_signals ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + signal_type VARCHAR(10) NOT NULL, + current_price INTEGER DEFAULT 0, + target_price INTEGER DEFAULT 0, + stop_loss INTEGER DEFAULT 0, + expected_return_pct FLOAT DEFAULT 0, + risk_reward_ratio FLOAT DEFAULT 0, + confidence FLOAT DEFAULT 0, + reason TEXT DEFAULT '', + news_score FLOAT DEFAULT 0, + dart_score FLOAT DEFAULT 0, + price_momentum FLOAT DEFAULT 0, + foreign_net_5d BIGINT DEFAULT 0, + short_weight FLOAT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() + )""") + await c.execute("CREATE INDEX IF NOT EXISTS idx_ts_code ON trade_signals(stock_code)") + await c.execute("CREATE INDEX IF NOT EXISTS idx_ts_time ON trade_signals(created_at DESC)") + logger.info("kiwoom.db.initialized") + +# ── 수집 함수 ───────────────────────────────────────────── + +async def fetch_basic_info(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore) -> Optional[dict]: + """ka10001: 현재가 + 재무지표 (PER/PBR/ROE/EPS/BPS/외국인비중/시가총액)""" + async with sem: + try: + d = await kiwoom_post(client, "/api/dostk/stkinfo", "ka10001", {"stk_cd": code}) + if d.get("return_code", -1) != 0: + return None + return { + "code": code, + "name": d.get("stk_nm", ""), + "price": abs(to_int(d.get("cur_prc", "0"))), + "change_amount": to_int(d.get("pred_pre", "0")), + "change_pct": to_float(d.get("flu_rt", "0")), + "volume": to_int(d.get("trde_qty", "0")), + "open_price": abs(to_int(d.get("open_pric", "0"))), + "high": abs(to_int(d.get("high_pric", "0"))), + "low": abs(to_int(d.get("low_pric", "0"))), + "market_cap": to_int(d.get("mac", "0")) * 100_000_000, # 억 → 원 + "per": to_float(d.get("per", "0")), + "pbr": to_float(d.get("pbr", "0")), + "eps": to_float(d.get("eps", "0")), + "bps": to_float(d.get("bps", "0")), + "roe": to_float(d.get("roe", "0")), + "ev": to_float(d.get("ev", "0")), + "high_52w": abs(to_int(d.get("250hgst", "0"))), + "low_52w": abs(to_int(d.get("250lwst", "0"))), + "foreign_ratio": to_float(d.get("for_exh_rt", "0")), + "credit_ratio": to_float(d.get("crd_rt", "0")), + "float_shares": to_int(d.get("flo_stk", "0")), + "sale_amt": to_int(d.get("sale_amt", "0")), + "operating_profit": to_int(d.get("bus_pro", "0")), + "net_income": to_int(d.get("cup_nga", "0")), + "timestamp": datetime.now().isoformat(), + } + except Exception as e: + logger.debug("ka10001.err", code=code, error=str(e)) + return None + +async def fetch_ohlcv(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore, days: int = 365) -> list: + """ka10005: 일봉 OHLCV + 외국인·기관 순매수""" + async with sem: + try: + today = datetime.now().strftime("%Y%m%d") + result = [] + cont_yn, next_key = "N", "" + for _ in range(20): # 안전 상한(20p ≈ 365봉 충분) + d, hdr = await kiwoom_post( + client, "/api/dostk/mrkcond", "ka10005", + {"stk_cd": code, "dt": today, "req_cnt": days}, + cont_yn=cont_yn, next_key=next_key, return_headers=True) + for r in d.get("stk_ddwkmm", []): + cp = abs(to_int(r.get("close_pric", "0"))) + if cp <= 0: + continue + result.append({ + "dt": r.get("date", ""), + "open": abs(to_int(r.get("open_pric", "0"))), + "high": abs(to_int(r.get("high_pric", "0"))), + "low": abs(to_int(r.get("low_pric", "0"))), + "close": cp, + "volume": to_int(r.get("trde_qty", "0")), + "trade_amount": to_int(r.get("trde_prica", "0")), + "foreign_ratio": to_float(r.get("for_poss", "0")), + "foreign_net": to_int(r.get("for_netprps", "0")), + "institution_net": to_int(r.get("orgn_netprps", "0")), + "individual_net": to_int(r.get("ind_netprps", "0")), + }) + # 키움 연속조회: 응답 cont-yn=Y + next-key 있으면 다음 페이지 + if (len(result) >= days or hdr.get("cont-yn") != "Y" + or not hdr.get("next-key")): + break + cont_yn, next_key = "Y", hdr.get("next-key", "") + return result + except Exception as e: + logger.debug("ka10005.err", code=code, error=str(e)) + return [] + +async def fetch_foreign_flow(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore) -> list: + """ka10008: 외국인 종목별 매매동향""" + async with sem: + try: + d = await kiwoom_post(client, "/api/dostk/frgnistt", "ka10008", {"stk_cd": code}) + rows = d.get("stk_frgnr", []) + result = [] + for r in rows: + result.append({ + "dt": r.get("dt", ""), + "close_price": abs(to_int(r.get("close_pric", "0"))), + "change_qty": to_int(r.get("chg_qty", "0")), + "hold_qty": to_int(r.get("poss_stkcnt", "0")), + "hold_ratio": to_float(r.get("wght", "0")), + "limit_ratio": to_float(r.get("limit_exh_rt", "0")), + }) + return result + except Exception as e: + logger.debug("ka10008.err", code=code, error=str(e)) + return [] + +async def fetch_minute_chart(client: httpx.AsyncClient, code: str, tic_scope: str = "1") -> list: + """ka10080: 주식분봉차트조회. tic_scope: 1, 3, 5, 10, 15, 30, 45, 60 분""" + try: + d = await kiwoom_post(client, "/api/dostk/chart", "ka10080", + {"stk_cd": code, "tic_scope": tic_scope, "upd_stkpc_tp": "1"}) + if d.get("return_code", -1) != 0: + return [] + rows = d.get("stk_min_pole_chart_qry", []) or d.get("stk_min_pole", []) or [] + result = [] + for r in rows: + result.append({ + "dt": r.get("cntr_tm", r.get("dt", "")), + "open": abs(to_int(r.get("open_pric", "0"))), + "high": abs(to_int(r.get("high_pric", "0"))), + "low": abs(to_int(r.get("low_pric", "0"))), + "close": abs(to_int(r.get("cur_prc", r.get("close_pric", "0")))), + "volume": to_int(r.get("trde_qty", "0")), + }) + return result + except Exception as e: + logger.debug("ka10080.err", code=code, error=str(e)) + return [] + +async def fetch_orderbook(client: httpx.AsyncClient, code: str) -> dict: + """ka10004: 호가잔량 (10단계 매수/매도). path=/api/dostk/mrkcond""" + try: + d = await kiwoom_post(client, "/api/dostk/mrkcond", "ka10004", {"stk_cd": code}) + if d.get("return_code", -1) != 0: + return {} + bid, ask = [], [] + # 매도 호가 1~10 (sel_1th_pre_bid = 가격, sel_1th_pre_req = 잔량) + for i in range(1, 11): + p = abs(to_int(d.get(f"sel_{i}th_pre_bid", "0"))) + q = to_int(d.get(f"sel_{i}th_pre_req", "0")) + if p > 0: + ask.append({"price": p, "qty": q}) + # 매수 호가 1~10 + for i in range(1, 11): + p = abs(to_int(d.get(f"buy_{i}th_pre_bid", "0"))) + q = to_int(d.get(f"buy_{i}th_pre_req", "0")) + if p > 0: + bid.append({"price": p, "qty": q}) + return { + "code": code, + "ask": ask, # 매도 (가격 낮은 것부터) + "bid": bid, # 매수 (가격 높은 것부터) + "ask_total": to_int(d.get("tot_sel_req", "0")), + "bid_total": to_int(d.get("tot_buy_req", "0")), + "base_time": d.get("bid_req_base_tm", ""), + "timestamp": datetime.now().isoformat(), + } + except Exception as e: + logger.debug("ka10004.err", code=code, error=str(e)) + return {} + +async def fetch_volume_surge() -> list: + """ka10023: 거래량급증 종목. path=/api/dostk/rkinfo""" + try: + async with httpx.AsyncClient() as c: + d = await kiwoom_post(c, "/api/dostk/rkinfo", "ka10023", + {"mrkt_tp": "000", # 000=전체, 001=코스피, 101=코스닥 + "sort_tp": "1", # 1=급증량, 2=급증률 + "tm_tp": "2", # 1=분, 2=전일 + "trde_qty_tp": "5", # 1=5천주, 2=1만주, 5=5만주 + "tm": "", + "stk_cnd": "0", # 0=전체 + "pric_tp": "0", # 0=전체 + "stex_tp": "3"}) # 3=통합 + if d.get("return_code", -1) != 0: + return [] + rows = d.get("trde_qty_sdnin", []) or [] + result = [] + for r in rows[:50]: + raw_code = r.get("stk_cd", "") + # NXT 등 접미사 제거 ('_AL', '_NX' 등) + clean_code = raw_code.split("_")[0] if raw_code else "" + result.append({ + "code": clean_code, + "name": r.get("stk_nm", ""), + "price": abs(to_int(r.get("cur_prc", "0"))), + "change_pct": to_float(r.get("flu_rt", "0")), + "volume": to_int(r.get("now_trde_qty", r.get("trde_qty", "0"))), + "prev_volume":to_int(r.get("pred_trde_qty", "0")), + "surge_rate": to_float(r.get("sdnin_rt", r.get("sdnin_qty", "0"))), + }) + return result + except Exception as e: + logger.debug("ka10023.err", error=str(e)) + return [] + +async def fetch_short_sale(client: httpx.AsyncClient, code: str, sem: asyncio.Semaphore) -> list: + """ka10014: 공매도 추이""" + async with sem: + try: + today = datetime.now().strftime("%Y%m%d") + month_ago = (datetime.now() - timedelta(days=30)).strftime("%Y%m%d") + d = await kiwoom_post(client, "/api/dostk/shsa", "ka10014", + {"stk_cd": code, "strt_dt": month_ago, "end_dt": today}) + rows = d.get("shrts_trnsn", []) + result = [] + for r in rows: + result.append({ + "dt": r.get("dt", ""), + "close_price": abs(to_int(r.get("close_pric", "0"))), + "short_qty": to_int(r.get("shrts_qty", "0")), + "short_balance_qty": to_int(r.get("ovr_shrts_qty", "0")), + "trade_weight": to_float(r.get("trde_wght", "0")), + "short_avg_price": to_int(r.get("shrts_avg_pric", "0")), + }) + return result + except Exception as e: + logger.debug("ka10014.err", code=code, error=str(e)) + return [] + +# ── 종목 코드 로드 ──────────────────────────────────────── + +async def get_stock_codes(limit: int = 0) -> list: + """is_active 종목 전체 (limit=0이면 제한 없음)""" + if pg_pool: + try: + if limit > 0: + rows = await pg_pool.fetch( + "SELECT stock_code FROM dart_corps WHERE is_active=TRUE ORDER BY stock_code LIMIT $1", limit) + else: + rows = await pg_pool.fetch( + "SELECT stock_code FROM dart_corps WHERE is_active=TRUE ORDER BY stock_code") + codes = [r["stock_code"] for r in rows if r["stock_code"]] + if len(codes) >= 50: + return codes + except Exception: + pass + return [] + +# ── 저장 함수 ───────────────────────────────────────────── + +async def save_price(info: dict): + if redis_cl: + redis_data = { + "code": info["code"], "name": info["name"], + "price": info["price"], "change_pct": info["change_pct"], + "change_amount": info["change_amount"], + "volume": info["volume"], "high": info["high"], "low": info["low"], + "open_price": info["open_price"], + "market_cap": info["market_cap"], + "per": info["per"], "pbr": info["pbr"], + "eps": info["eps"], "bps": info["bps"], + "roe": info["roe"], "ev": info["ev"], + "high_52w": info["high_52w"], "low_52w": info["low_52w"], + "foreign_ratio": info["foreign_ratio"], + "credit_ratio": info["credit_ratio"], + "timestamp": info["timestamp"], + } + await redis_cl.set(f"price:{info['code']}", json.dumps(redis_data, ensure_ascii=False), ex=600) + + if pg_pool: + try: + async with pg_pool.acquire() as c: + await c.execute(""" + INSERT INTO stock_prices ( + stock_code, stock_name, price, change_pct, change_amount, + volume, high, low, open_price, market_cap, + per, pbr, eps, bps, roe, ev, + high_52w, low_52w, foreign_ratio, credit_ratio, float_shares, collected_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22) + """, info["code"], info["name"], info["price"], info["change_pct"], info["change_amount"], + info["volume"], info["high"], info["low"], info["open_price"], info["market_cap"], + info["per"], info["pbr"], info["eps"], info["bps"], info["roe"], info["ev"], + info["high_52w"], info["low_52w"], info["foreign_ratio"], info["credit_ratio"], + info["float_shares"], datetime.now()) + except Exception as e: + logger.debug("save_price.err", code=info["code"], error=str(e)) + +async def save_ohlcv(code: str, rows: list): + if not rows or not pg_pool: + return + # Redis에 최근 60일 일봉 저장 (ta-engine 보조) + if redis_cl: + await redis_cl.set(f"ohlcv:{code}", json.dumps(rows[:60], ensure_ascii=False), ex=86400) + async with pg_pool.acquire() as c: + for r in rows: + try: + dt = datetime.strptime(r["dt"], "%Y%m%d").date() + await c.execute(""" + INSERT INTO stock_ohlcv ( + stock_code, dt, open_price, high_price, low_price, close_price, + volume, trade_amount, foreign_ratio, foreign_net, institution_net, individual_net) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + ON CONFLICT (stock_code, dt) DO UPDATE SET + close_price=$6, volume=$7, foreign_ratio=$9, + foreign_net=$10, institution_net=$11, individual_net=$12 + """, code, dt, r["open"], r["high"], r["low"], r["close"], + r["volume"], r["trade_amount"], r["foreign_ratio"], + r["foreign_net"], r["institution_net"], r["individual_net"]) + except Exception: + pass + +async def save_foreign_flow(code: str, rows: list): + if not rows or not pg_pool: + return + if redis_cl and rows: + await redis_cl.set(f"foreign:{code}", json.dumps(rows[:20], ensure_ascii=False), ex=86400) + async with pg_pool.acquire() as c: + for r in rows: + try: + dt = datetime.strptime(r["dt"], "%Y%m%d").date() + await c.execute(""" + INSERT INTO stock_foreign_flow ( + stock_code, dt, close_price, change_qty, hold_qty, hold_ratio, limit_ratio) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (stock_code, dt) DO UPDATE SET + change_qty=$4, hold_qty=$5, hold_ratio=$6, limit_ratio=$7 + """, code, dt, r["close_price"], r["change_qty"], + r["hold_qty"], r["hold_ratio"], r["limit_ratio"]) + except Exception: + pass + +async def save_short_sale(code: str, rows: list): + if not rows or not pg_pool: + return + if redis_cl and rows: + await redis_cl.set(f"short:{code}", json.dumps(rows[:20], ensure_ascii=False), ex=86400) + async with pg_pool.acquire() as c: + for r in rows: + try: + dt = datetime.strptime(r["dt"], "%Y%m%d").date() + await c.execute(""" + INSERT INTO stock_short_sale ( + stock_code, dt, close_price, short_qty, short_balance_qty, + trade_weight, short_avg_price) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (stock_code, dt) DO UPDATE SET + short_qty=$4, short_balance_qty=$5, trade_weight=$6, short_avg_price=$7 + """, code, dt, r["close_price"], r["short_qty"], + r["short_balance_qty"], r["trade_weight"], r["short_avg_price"]) + except Exception: + pass + +# ── 수집 작업 ───────────────────────────────────────────── + +class Stats: + collected = 0; errors = 0; last_run = ""; last_full_run = "" + +stats = Stats() + +async def job_price(): + """평일 9~16시 5분마다: 현재가·재무지표 수집 (ka10001)""" + codes = await get_stock_codes(0) + if not codes: + logger.warning("job_price.no_codes") + return + sem = asyncio.Semaphore(10) # 동시 10개 제한 + ok = 0 + async with httpx.AsyncClient() as client: + tasks = [fetch_basic_info(client, code, sem) for code in codes] + results = await asyncio.gather(*tasks, return_exceptions=True) + for info in results: + if isinstance(info, dict) and info.get("price", 0) > 0: + await save_price(info) + ok += 1 + else: + stats.errors += 1 + await gen_signals() + stats.collected += ok + stats.last_run = datetime.now().isoformat() + if redis_cl: + await redis_cl.set("prices:last_update", datetime.now().isoformat()) + logger.info("job_price.done", ok=ok, total=len(codes)) + +async def job_full(days: int = 10): + """평일 17:00: 일봉·외국인·공매도 전체 수집 (ka10005·ka10008·ka10014). + 일별은 days=10(경량, ON CONFLICT 갱신). 백필은 /collect/full?days=400.""" + codes = await get_stock_codes(0) + if not codes: + return + sem = asyncio.Semaphore(5) # 야간 수집 느리게 + ok = 0 + async with httpx.AsyncClient() as client: + for code in codes: + try: + ohlcv, foreign, short = await asyncio.gather( + fetch_ohlcv(client, code, sem, days), + fetch_foreign_flow(client, code, sem), + fetch_short_sale(client, code, sem), + return_exceptions=True + ) + if isinstance(ohlcv, list): + await save_ohlcv(code, ohlcv) + if isinstance(foreign, list): + await save_foreign_flow(code, foreign) + if isinstance(short, list): + await save_short_sale(code, short) + ok += 1 + except Exception as e: + logger.debug("job_full.err", code=code, error=str(e)) + stats.last_full_run = datetime.now().isoformat() + logger.info("job_full.done", ok=ok, total=len(codes)) + +# ── 시그널 생성 ─────────────────────────────────────────── + +def calc_signal(info: dict, news_sc: float, dart_sc: float, + foreign_net_5d: int, short_weight: float) -> Optional[dict]: + price = info["price"] + if price <= 0: + return None + h52 = info.get("high_52w", 0) + l52 = info.get("low_52w", 0) + cpct = info.get("change_pct", 0) + foreign_ratio = info.get("foreign_ratio", 0) + + # 외국인 수급 보너스 (5일 누적 순매수 양수면 +10, 음수면 -10) + foreign_bonus = min(10, max(-10, foreign_net_5d / 500_000)) if foreign_net_5d else 0 + # 공매도 패널티 (비중 5% 이상이면 -5) + short_penalty = -5 if short_weight >= 5 else 0 + + total = (news_sc * 0.35 + dart_sc * 0.25 + + (cpct * 10) * 0.25 + foreign_bonus * 0.10 + short_penalty * 0.05) + total = max(-100, min(100, total)) + pos = (price - l52) / (h52 - l52) if h52 > l52 > 0 else 0.5 + + if total >= 30: + sig = "매수" + tp = int(price + (h52 - price) * (0.3 + min(total, 100) / 200)) if h52 > price else int(price * 1.10) + sl = int(price * (0.92 + pos * 0.03)) + conf = min(95, 50 + total * 0.3 + (1 - pos) * 20) + elif total <= -30: + sig = "매도" + tp = int(price - (price - l52) * (0.3 + min(abs(total), 100) / 200)) if l52 > 0 and l52 < price else int(price * 0.90) + sl = int(price * 1.05) + conf = min(95, 50 + abs(total) * 0.3 + pos * 20) + else: + return None + + er = ((tp - price) / price) * 100 if sig == "매수" else ((price - tp) / price) * 100 + risk = abs(sl - price) + reward = abs(tp - price) + rr = reward / risk if risk > 0 else 0 + return { + "signal_type": sig, "current_price": price, "target_price": tp, "stop_loss": sl, + "expected_return_pct": round(er, 2), "risk_reward_ratio": round(rr, 2), + "confidence": round(conf, 1), "price_momentum": round(cpct, 2), + "news_score": round(news_sc, 1), "dart_score": round(dart_sc, 1), + "foreign_net_5d": foreign_net_5d, "short_weight": short_weight, + } + +async def gen_signals(): + if not pg_pool: + return + async with pg_pool.acquire() as c: + scores = await c.fetch(""" + SELECT stock_code, stock_name, news_score, dart_score, total_score + FROM stock_scores + WHERE score_date = (SELECT MAX(score_date) FROM stock_scores) + AND (total_score >= 30 OR total_score <= -30) + """) + n = 0 + for row in scores: + code = row["stock_code"] + if not code or len(code) != 6: + continue + if not redis_cl: + continue + cached = await redis_cl.get(f"price:{code}") + if not cached: + continue + info = json.loads(cached) + + # 외국인 5일 순매수 합산 + foreign_net_5d = 0 + f_cached = await redis_cl.get(f"foreign:{code}") + if f_cached: + f_rows = json.loads(f_cached) + foreign_net_5d = sum(r.get("change_qty", 0) for r in f_rows[:5]) + + # 공매도 최근 비중 + short_weight = 0.0 + s_cached = await redis_cl.get(f"short:{code}") + if s_cached: + s_rows = json.loads(s_cached) + if s_rows: + short_weight = s_rows[0].get("trade_weight", 0.0) + + sig = calc_signal(info, row["news_score"], row["dart_score"], + foreign_net_5d, short_weight) + if not sig: + continue + reasons = await c.fetch(""" + SELECT reason FROM news_analysis + WHERE primary_stock=$1 AND intensity>=2 + ORDER BY analyzed_at DESC LIMIT 2 + """, code) + reason = " | ".join([r["reason"][:100] for r in reasons]) + await c.execute(""" + INSERT INTO trade_signals ( + stock_code, stock_name, signal_type, current_price, + target_price, stop_loss, expected_return_pct, risk_reward_ratio, + confidence, reason, news_score, dart_score, price_momentum, + foreign_net_5d, short_weight) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) + ON CONFLICT (stock_code, (created_at::date)) DO UPDATE SET + signal_type=EXCLUDED.signal_type, current_price=EXCLUDED.current_price, + target_price=EXCLUDED.target_price, stop_loss=EXCLUDED.stop_loss, + expected_return_pct=EXCLUDED.expected_return_pct, + risk_reward_ratio=EXCLUDED.risk_reward_ratio, + confidence=EXCLUDED.confidence, reason=EXCLUDED.reason, + news_score=EXCLUDED.news_score, dart_score=EXCLUDED.dart_score, + price_momentum=EXCLUDED.price_momentum, + foreign_net_5d=EXCLUDED.foreign_net_5d, short_weight=EXCLUDED.short_weight + """, code, row["stock_name"], sig["signal_type"], sig["current_price"], + sig["target_price"], sig["stop_loss"], sig["expected_return_pct"], + sig["risk_reward_ratio"], sig["confidence"], reason, + sig["news_score"], sig["dart_score"], sig["price_momentum"], + sig["foreign_net_5d"], sig["short_weight"]) + n += 1 + logger.info("signals.done", count=n) + +# ── FastAPI ─────────────────────────────────────────────── + +app = FastAPI(title="키움증권 주가·수급·공매도") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.on_event("startup") +async def startup(): + global pg_pool, redis_cl + pg_pool = await asyncpg.create_pool( + host=PG_HOST, port=PG_PORT, database=PG_DB, + user=PG_USER, password=PG_PASS, min_size=2, max_size=5) + redis_cl = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True) + await init_db() + # 현재가: 평일 9~16시 5분마다 + scheduler.add_job(job_price, "cron", day_of_week="mon-fri", + hour="9-15", minute="*/5", id="price", replace_existing=True) + # 장 마감 후 전체 수집: 평일 17:00 + scheduler.add_job(job_full, "cron", day_of_week="mon-fri", + hour="17", minute="0", id="full", replace_existing=True) + scheduler.start() + logger.info("kiwoom-api.started") + +@app.on_event("shutdown") +async def shutdown(): + scheduler.shutdown() + if pg_pool: + await pg_pool.close() + if redis_cl: + await redis_cl.aclose() + +@app.get("/health") +async def health(): + lu = await redis_cl.get("prices:last_update") if redis_cl else "" + return JSONResponse(content={ + "status": "ok", "collected": stats.collected, "errors": stats.errors, + "last_run": stats.last_run, "last_full_run": stats.last_full_run, + "last_update": lu or "" + }) + +@app.get("/price/{code}") +async def price(code: str): + if redis_cl: + c = await redis_cl.get(f"price:{code}") + if c: + return JSONResponse(content=json.loads(c)) + # 실시간 단건 조회 + sem = asyncio.Semaphore(1) + async with httpx.AsyncClient() as client: + info = await fetch_basic_info(client, code, sem) + if info: + await save_price(info) + return JSONResponse(content=info) + return JSONResponse(content={"error": "not found"}, status_code=404) + +@app.get("/prices") +async def prices(limit: int = Query(default=50)): + if not redis_cl: + return JSONResponse(content={"data": {}}) + keys = await redis_cl.keys("price:*") + result = {} + for k in keys[:limit]: + c = await redis_cl.get(k) + if c: + d = json.loads(c) + result[d["code"]] = d + return JSONResponse(content={"count": len(result), "data": result}) + +@app.get("/foreign/{code}") +async def foreign(code: str): + if redis_cl: + c = await redis_cl.get(f"foreign:{code}") + if c: + return JSONResponse(content={"code": code, "data": json.loads(c)}) + sem = asyncio.Semaphore(1) + async with httpx.AsyncClient() as client: + rows = await fetch_foreign_flow(client, code, sem) + await save_foreign_flow(code, rows) + return JSONResponse(content={"code": code, "data": rows}) + +@app.get("/short/{code}") +async def short_sale(code: str): + if redis_cl: + c = await redis_cl.get(f"short:{code}") + if c: + return JSONResponse(content={"code": code, "data": json.loads(c)}) + sem = asyncio.Semaphore(1) + async with httpx.AsyncClient() as client: + rows = await fetch_short_sale(client, code, sem) + await save_short_sale(code, rows) + return JSONResponse(content={"code": code, "data": rows}) + +@app.get("/ohlcv/{code}") +async def ohlcv(code: str, days: int = Query(default=60)): + if redis_cl: + c = await redis_cl.get(f"ohlcv:{code}") + if c: + data = json.loads(c) + return JSONResponse(content={"code": code, "data": data[:days]}) + sem = asyncio.Semaphore(1) + async with httpx.AsyncClient() as client: + rows = await fetch_ohlcv(client, code, sem, days) + await save_ohlcv(code, rows) + return JSONResponse(content={"code": code, "data": rows}) + +@app.get("/signals") +async def signals(days: int = Query(default=7)): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT * FROM trade_signals + WHERE created_at >= NOW() - INTERVAL '%s days' + ORDER BY confidence DESC LIMIT 50 + """ % days) + return [dict(r) for r in rows] + +@app.get("/signals/{code}") +async def stock_signals(code: str): + async with pg_pool.acquire() as c: + rows = await c.fetch(""" + SELECT * FROM trade_signals WHERE stock_code=$1 + ORDER BY created_at DESC LIMIT 10 + """, code) + return [dict(r) for r in rows] + +@app.get("/summary/{code}") +async def summary(code: str): + """종목 종합 요약 (현재가 + 외국인 + 공매도 + 최근 신호)""" + result = {"code": code} + if redis_cl: + for key in [f"price:{code}", f"foreign:{code}", f"short:{code}", f"ohlcv:{code}"]: + c = await redis_cl.get(key) + if c: + field = key.split(":")[0] + result[field] = json.loads(c) if field != "price" else json.loads(c) + return JSONResponse(content=result) + +@app.post("/collect/price") +async def collect_price(): + asyncio.create_task(job_price()) + return {"status": "started", "job": "price"} + +@app.post("/collect/full") +async def collect_full(days: int = Query(default=10, ge=1, le=600)): + asyncio.create_task(job_full(days)) + return {"status": "started", "job": "full", "days": days} + +# ── 추가: 분봉 / 호가 / 거래량급증 ───────────────────────── + +@app.get("/minute/{code}") +async def minute_chart(code: str, scope: str = Query(default="5", description="분봉 단위: 1,3,5,10,15,30,60")): + """ka10080: 분봉차트 (실시간 호출)""" + async with httpx.AsyncClient() as c: + data = await fetch_minute_chart(c, code, scope) + return JSONResponse(content={"code": code, "scope": scope, "data": data}) + +@app.get("/orderbook/{code}") +async def orderbook(code: str): + """ka10004: 10단계 호가 잔량""" + async with httpx.AsyncClient() as c: + data = await fetch_orderbook(c, code) + return JSONResponse(content=data or {"code": code, "ask": [], "bid": []}) + +@app.get("/volume-surge") +async def volume_surge(): + """ka10023: 거래량 급증 종목 TOP50 (Redis 5분 캐시)""" + cached = None + if redis_cl: + try: + v = await redis_cl.get("vol_surge:list") + if v: cached = json.loads(v) + except: pass + if cached: + return JSONResponse(content={"data": cached, "cached": True}) + data = await fetch_volume_surge() + if redis_cl and data: + try: await redis_cl.setex("vol_surge:list", 300, json.dumps(data)) + except: pass + return JSONResponse(content={"data": data, "cached": False}) diff --git a/kis-api/requirements.txt b/kis-api/requirements.txt new file mode 100644 index 0000000..2db99d4 --- /dev/null +++ b/kis-api/requirements.txt @@ -0,0 +1,8 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +httpx==0.27.0 +redis==5.0.4 +asyncpg==0.29.0 +apscheduler==3.10.4 +orjson==3.10.3 +structlog==24.2.0 diff --git a/main.py b/main.py new file mode 100644 index 0000000..18ed11f --- /dev/null +++ b/main.py @@ -0,0 +1,269 @@ +""" +네이버 금융 뉴스 수집기 + AI 분석 파이프라인 +- 시장 전체 뉴스 (5분마다) +- 시총 상위 200개 종목별 뉴스 (30분마다) +- 수집 즉시 바른API → Ollama → Qdrant → vLLM → PostgreSQL +""" +import asyncio, hashlib, json, os, re, random, time +from datetime import datetime, timedelta +from typing import Optional +import asyncpg, httpx, redis.asyncio as aioredis, structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from bs4 import BeautifulSoup +from fastapi import FastAPI, Query +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +PG_HOST = os.getenv("POSTGRES_HOST", "postgres") +PG_PORT = int(os.getenv("POSTGRES_PORT", "5432")) +PG_DB = os.getenv("POSTGRES_DB", "trading_ai") +PG_USER = os.getenv("POSTGRES_USER", "kyu") +PG_PASS = os.getenv("POSTGRES_PASSWORD", "7895123") +BAREUN_URL = os.getenv("BAREUN_API_URL", "http://bareunaapi:5757") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") +VLLM_URL = os.getenv("VLLM_URL", "http://vllm:8000") +QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333") + +HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + +pg_pool: Optional[asyncpg.Pool] = None +redis_cl: Optional[aioredis.Redis] = None +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + +class S: + collected = 0; processed = 0; duplicates = 0; errors = 0 + last_run = ""; running = False +stats = S() + +def nhash(title, url=""): return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16] + +async def is_dup(h): + if not redis_cl: return False + try: + r = await redis_cl.set(f"news:naver:{h}", "1", ex=86400, nx=True) + return r is None + except: return False + +# ── 크롤러 ──────────────────────────────────────────────── +async def crawl_market_news(client): + news = [] + urls = [ + "https://finance.naver.com/news/mainnews.naver", + "https://finance.naver.com/news/news_list.naver?mode=LSS2D§ion_id=101§ion_id2=258", + "https://finance.naver.com/news/news_list.naver?mode=LSS2D§ion_id=101§ion_id2=259", + "https://finance.naver.com/news/news_list.naver?mode=LSS2D§ion_id=101§ion_id2=261", + ] + for url in urls: + try: + r = await client.get(url, headers=HEADERS, timeout=15) + r.encoding = "euc-kr" + soup = BeautifulSoup(r.text, "lxml") + for a in soup.select("a[href*='article_id']"): + t = a.get_text(strip=True) + h = a.get("href", "") + if t and len(t) > 10: + news.append({"title": t, "url": f"https://finance.naver.com{h}" if h.startswith("/") else h, + "source": "네이버금융", "content": "", "published_at": datetime.now().isoformat()}) + await asyncio.sleep(0.3) + except Exception as e: + logger.warning("crawl.market.err", error=str(e)) + return news + +async def crawl_stock_news(client, code, name): + news = [] + try: + r = await client.get(f"https://finance.naver.com/item/news_news.naver?code={code}&page=1", headers=HEADERS, timeout=15) + r.encoding = "euc-kr" + soup = BeautifulSoup(r.text, "lxml") + for tr in soup.select("table.type5 tr"): + a = tr.select_one("td.title a") + dt = tr.select_one("td.date") + src = tr.select_one("td.info") + if a: + t = a.get_text(strip=True) + if t and len(t) > 5: + news.append({"title": t, "url": f"https://finance.naver.com{a.get('href','')}", + "source": src.get_text(strip=True) if src else "네이버금융", + "content": f"[{name}({code})] {t}", + "published_at": dt.get_text(strip=True) if dt else datetime.now().isoformat(), + "stock_code": code, "stock_name": name}) + except: pass + return news + +async def get_top_stocks(client, count=200): + stocks = [] + for sosok in [0, 1]: + for page in range(1, 50): + try: + r = await client.get(f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}", headers=HEADERS, timeout=15) + r.encoding = "euc-kr" + rows = re.findall(r'main\.naver\?code=(\d{6})[^>]*>([^<]+)', r.text) + if not rows: break + for c, n in rows: stocks.append({"code": c.strip(), "name": n.strip()}) + await asyncio.sleep(0.2) + except: break + if len(stocks) >= count: break + return stocks[:count] + +# ── 파이프라인 ───────────────────────────────────────────── +async def pipeline(item, client): + try: + # 1. 바른API + br = await client.post(f"{BAREUN_URL}/analyze", json={ + "title": item["title"], "content": item.get("content",""), + "url": item.get("url",""), "source": item.get("source",""), + "published_at": item.get("published_at","")}, timeout=30) + bd = br.json() + if bd.get("is_duplicate"): return "dup" + + data = {**item, "hash": bd.get("hash",""), "stocks": bd.get("stocks",[]), + "keywords": bd.get("keywords",[]), "filtered_text": bd.get("filtered_text","")} + + # 2. Ollama 임베딩 + er = await client.post(f"{OLLAMA_URL}/api/embeddings", + json={"model":"bge-m3","prompt": data["filtered_text"] or data["title"]}, timeout=60) + emb = er.json().get("embedding") + if not emb: return "no_embed" + + # 3. Qdrant 유사도 + try: + sr = await client.post(f"{QDRANT_URL}/collections/news_vectors/points/search", + json={"vector":emb,"limit":3,"score_threshold":0.85,"with_payload":True}, timeout=15) + hits = sr.json().get("result",[]) + if any(h["score"]>=0.92 and h["score"]<0.99 for h in hits): return "sim_dup" + except: hits = [] + + # 4. vLLM 분석 + stocks_str = ", ".join([f'{s["name"]}({s["code"]})' for s in data["stocks"][:5]]) + prompt = (f"뉴스: {data['title'][:200]}\n키워드: {(data['filtered_text'] or '')[:300]}\n" + f"감지된 종목: {stocks_str}\n\n" + "JSON 응답. primary_stock에 6자리 종목코드, 시장전체면 KOSPI/KOSDAQ:\n" + '{"sentiment":"호재/악재/중립","intensity":1~5,"primary_stock":"종목코드",' + '"affected_stocks":["코드"],"reason":"근거","investment_action":"매수관심/매도관심/관망"}') + vr = await client.post(f"{VLLM_URL}/v1/chat/completions", json={ + "model":"exaone","messages":[ + {"role":"system","content":"한국 주식 전문 애널리스트. JSON만 응답."}, + {"role":"user","content":prompt}], + "max_tokens":300,"temperature":0.1}, timeout=120) + try: + c = vr.json()["choices"][0]["message"]["content"] + a = json.loads(re.sub(r"```json\n?|```","",c).strip()) + except: + a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],"reason":"파싱실패","investment_action":"관망"} + + # 5. Qdrant 저장 + try: + await client.put(f"{QDRANT_URL}/collections/news_vectors/points", json={ + "points":[{"id":random.randint(1,999999999),"vector":emb, + "payload":{"title":data["title"],"hash":data["hash"], + "sentiment":a.get("sentiment",""),"intensity":a.get("intensity",0), + "primary_stock":a.get("primary_stock","")}}]}, timeout=15) + except: pass + + # 6. PostgreSQL 저장 + esc = lambda s: (s or "").replace("'","''") + await pg_pool.execute(f""" + INSERT INTO news_analysis (title,url,source,published_at,hash,sentiment,intensity, + primary_stock,affected_stocks,reason,investment_action,keywords,stock_names,stock_codes,similar_count,analyzed_at) + VALUES ('{esc(data["title"][:500])}','{esc(data.get("url","")[:500])}','{esc(data.get("source","")[:100])}', + '{data.get("published_at",datetime.now().isoformat())}','{data["hash"]}', + '{a.get("sentiment","중립")}',{a.get("intensity",0)},'{esc(a.get("primary_stock",""))}', + '{json.dumps(a.get("affected_stocks",[]))}','{esc(a.get("reason","")[:500])}', + '{a.get("investment_action","관망")}','{json.dumps(data.get("keywords",[])[:20])}', + '{json.dumps([s["name"] for s in data.get("stocks",[])])}', + '{json.dumps([s["code"] for s in data.get("stocks",[])])}', + 0,'{datetime.now().isoformat()}') + ON CONFLICT (hash) DO NOTHING""") + return "ok" + except Exception as e: + logger.warning("pipeline.err", title=item.get("title","")[:50], error=str(e)) + return "error" + +# ── 수집 작업 ────────────────────────────────────────────── +async def job_market(): + if stats.running: return + stats.running = True + try: + async with httpx.AsyncClient() as c: + news = await crawl_market_news(c) + ok = 0 + for item in news: + h = nhash(item["title"], item.get("url","")) + if await is_dup(h): stats.duplicates+=1; continue + stats.collected += 1 + r = await pipeline(item, c) + if r == "ok": ok += 1; stats.processed += 1 + await asyncio.sleep(0.5) + stats.last_run = datetime.now().isoformat() + logger.info("job.market", total=len(news), processed=ok) + except Exception as e: + stats.errors += 1; logger.error("job.market.err", error=str(e)) + finally: + stats.running = False + +async def job_stocks(): + if stats.running: return + stats.running = True + try: + async with httpx.AsyncClient() as c: + top = await get_top_stocks(c, 200) + ok = 0 + for stock in top: + try: + news = await crawl_stock_news(c, stock["code"], stock["name"]) + for item in news: + h = nhash(item["title"], item.get("url","")) + if await is_dup(h): stats.duplicates+=1; continue + stats.collected += 1 + r = await pipeline(item, c) + if r == "ok": ok += 1; stats.processed += 1 + await asyncio.sleep(0.3) + except: pass + stats.last_run = datetime.now().isoformat() + logger.info("job.stocks", stocks=len(top), processed=ok) + except Exception as e: + stats.errors += 1; logger.error("job.stocks.err", error=str(e)) + finally: + stats.running = False + +# ── FastAPI ──────────────────────────────────────────────── +app = FastAPI(title="뉴스 수집기") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.on_event("startup") +async def startup(): + global pg_pool, redis_cl + pg_pool = await asyncpg.create_pool(host=PG_HOST,port=PG_PORT,database=PG_DB,user=PG_USER,password=PG_PASS,min_size=2,max_size=5) + redis_cl = aioredis.Redis(host=REDIS_HOST,port=6379,password=REDIS_PASSWORD,db=4,decode_responses=True) + scheduler.add_job(job_market,"cron",day_of_week="mon-fri",hour="8-18",minute="*/5",id="market",replace_existing=True) + scheduler.add_job(job_stocks,"cron",day_of_week="mon-fri",hour="9-16",minute="*/30",id="stocks",replace_existing=True) + scheduler.start() + logger.info("news-collector.started") + +@app.on_event("shutdown") +async def shutdown(): + scheduler.shutdown() + if pg_pool: await pg_pool.close() + if redis_cl: await redis_cl.aclose() + +@app.get("/health") +async def health(): + return JSONResponse(content={"status":"ok","collected":stats.collected,"processed":stats.processed, + "duplicates":stats.duplicates,"errors":stats.errors,"last_run":stats.last_run,"running":stats.running}) + +@app.post("/collect/market") +async def m_market(): + asyncio.create_task(job_market()); return {"status":"started","type":"market"} + +@app.post("/collect/stocks") +async def m_stocks(): + asyncio.create_task(job_stocks()); return {"status":"started","type":"stocks"} diff --git a/news-collector/Dockerfile b/news-collector/Dockerfile new file mode 100644 index 0000000..940afa8 --- /dev/null +++ b/news-collector/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8787 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8787", "--workers", "1", "--log-level", "info"] diff --git a/news-collector/main.py b/news-collector/main.py new file mode 100644 index 0000000..2bacf72 --- /dev/null +++ b/news-collector/main.py @@ -0,0 +1,886 @@ +""" +멀티소스 금융 뉴스 수집기 + AI 분석 파이프라인 (버핏 스타일 강화) +- RSS 28개 소스 (네이버 크롤링 제거, 주식 전문 사이트 중심) +- 매 5분마다 전체 소스 수집 +- 2단계 중복제거: URL해시(Redis) + 제목정규화해시(Redis) + 벡터유사도(Qdrant) +- 수집 즉시 바른API → Ollama → Qdrant → Ollama(EXAONE) → PostgreSQL +""" +import asyncio, hashlib, json, os, re, random, time +import xml.etree.ElementTree as ET +from datetime import datetime, timedelta +from typing import Optional +import asyncpg, httpx, redis.asyncio as aioredis, structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from bs4 import BeautifulSoup +from fastapi import FastAPI, Query +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +PG_HOST = os.getenv("POSTGRES_HOST", "postgres") +PG_PORT = int(os.getenv("POSTGRES_PORT", "5432")) +PG_DB = os.getenv("POSTGRES_DB", "trading_ai") +PG_USER = os.getenv("POSTGRES_USER", "kyu") +PG_PASS = os.getenv("POSTGRES_PASSWORD", "7895123") +BAREUN_URL = os.getenv("BAREUN_API_URL", "http://bareunaapi:5757") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") +QDRANT_URL = os.getenv("QDRANT_URL", "http://qdrant:6333") + +HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + +pg_pool: Optional[asyncpg.Pool] = None +redis_cl: Optional[aioredis.Redis] = None +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + +class S: + collected = 0; processed = 0; duplicates = 0; errors = 0 + noise = 0; off_topic = 0 + last_run = ""; running = False +stats = S() + +def nhash(title, url=""): return hashlib.sha256(f"{title.strip()}{url.strip()}".encode()).hexdigest()[:16] + +def normalize_title(title: str) -> str: + """[속보][단독](종합) 등 제거 후 특수문자·공백 제거 → 유사 제목 중복 감지용""" + t = re.sub(r'[\[\(【〔][^\]\)】〕]{0,10}[\]\)】〕]', '', title) + t = re.sub(r'[^\w가-힣a-zA-Z0-9]', '', t) + return t.lower().strip() + +def is_korean(text: str) -> bool: + if not text: return True + hangul = sum(1 for c in text if '가' <= c <= '힣') + cjk = sum(1 for c in text if '一' <= c <= '鿿' or '㐀' <= c <= '䶿') + if hangul == 0 and cjk > 2: return False + return True + +async def is_dup(title: str, url: str = "") -> bool: + if not redis_cl: return False + try: + # 1차: URL+제목 해시 (완전 동일 기사) + h_url = nhash(title, url) + if await redis_cl.set(f"news:u:{h_url}", "1", ex=172800, nx=True) is None: + return True + # 2차: 정규화된 제목 해시 (같은 뉴스 다른 소스) + h_norm = nhash(normalize_title(title)) + if await redis_cl.set(f"news:t:{h_norm}", "1", ex=21600, nx=True) is None: + return True + return False + except: return False + +# ── RSS 멀티소스 수집 (28개 소스, 네이버 크롤링 제거) ────── + +RSS_SOURCES = [ + # ── 주요 경제/금융 신문 ── + ("한국경제", "https://www.hankyung.com/feed/all-news"), + ("한국경제", "https://www.hankyung.com/feed/finance"), + ("매일경제", "https://www.mk.co.kr/rss/30100041/"), + ("매일경제", "https://www.mk.co.kr/rss/30200030/"), + ("머니투데이", "https://news.mt.co.kr/mtview.php?type=rss&MTPub=E§ion=E"), + ("머니투데이", "https://news.mt.co.kr/mtview.php?type=rss&MTPub=S"), + ("이데일리", "https://www.edaily.co.kr/rss/news/finance.xml"), + ("이데일리", "https://www.edaily.co.kr/rss/news/markets.xml"), + ("연합뉴스", "https://www.yna.co.kr/rss/economy.xml"), + ("연합뉴스", "https://www.yna.co.kr/rss/market.xml"), + ("조선비즈", "https://biz.chosun.com/rss/rss.htm?site=biz.chosun.com"), + ("헤럴드경제", "https://biz.heraldcorp.com/rss/index.xml"), + ("파이낸셜뉴스", "https://www.fnnews.com/rss/fn_economy.xml"), + ("서울경제", "https://www.sedaily.com/RSS/Economy"), + ("아시아경제", "https://www.asiae.co.kr/rss/economy.htm"), + ("뉴스1", "https://www.news1.kr/rss/industry.xml"), + ("비즈니스포스트", "https://www.businesspost.co.kr/BP?command=rss&rssFeedType=0000"), + ("인포스탁", "https://www.infostock.co.kr/rss/rss_news.xml"), + # ── 추가 소스 ── + ("뉴스핌", "https://www.newspim.com/rss/view?outtype=1"), + ("뉴시스", "https://www.newsis.com/rss/finance.xml"), + ("이투데이", "https://www.etoday.co.kr/news/newsfeed.php?CateId=0102"), + ("글로벌이코노믹", "https://www.g-enews.com/rss/rss_economy.xml"), + ("전자신문", "https://www.etnews.com/news/latest_news.xml"), + ("디지털타임스", "https://www.dt.co.kr/rss/rss_economy.html"), + ("더벨", "https://www.thebell.co.kr/free/content/xmlService.asp"), + ("스탁데일리", "https://www.stockdaily.kr/rss/rss.xml"), + ("세계일보", "https://www.segye.com/RSS/economyRss.xml"), + ("SBS Biz", "https://news.sbs.co.kr/news/SectionRssFeed.do?sectionId=EC"), +] + +def parse_rss_date(date_str: str) -> str: + if not date_str: + return datetime.now().isoformat() + for fmt in ("%a, %d %b %Y %H:%M:%S %z", "%a, %d %b %Y %H:%M:%S +0900", + "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%d %H:%M:%S"): + try: + return datetime.strptime(date_str.strip(), fmt).isoformat() + except: pass + return datetime.now().isoformat() + +async def crawl_rss_sources(client) -> list: + news = [] + for source_name, rss_url in RSS_SOURCES: + try: + r = await client.get(rss_url, timeout=15, + headers={"User-Agent": "Mozilla/5.0", "Accept": "application/rss+xml, application/xml"}) + if r.status_code != 200: + continue + try: + root = ET.fromstring(r.content) + except ET.ParseError: + # XML 파싱 실패 시 BeautifulSoup 폴백 + soup = BeautifulSoup(r.content, "lxml-xml") + items = soup.find_all("item") + for item in items[:20]: + title = item.find("title") + link = item.find("link") + pub = item.find("pubDate") or item.find("dc:date") + if title and title.get_text(strip=True): + t = title.get_text(strip=True) + if len(t) > 5 and is_korean(t): + news.append({ + "title": t, + "url": link.get_text(strip=True) if link else "", + "source": source_name, + "content": "", + "published_at": parse_rss_date(pub.get_text(strip=True) if pub else ""), + }) + continue + + ns = {"dc": "http://purl.org/dc/elements/1.1/"} + items = root.findall(".//item") + for item in items[:20]: + title = item.findtext("title", "").strip() + link = item.findtext("link", "").strip() + pubdate = item.findtext("pubDate", "") or item.findtext("dc:date", "", ns) + desc = item.findtext("description", "") + if title and len(title) > 5 and is_korean(title): + news.append({ + "title": title, + "url": link, + "source": source_name, + "content": re.sub(r"<[^>]+>", "", desc)[:300] if desc else "", + "published_at": parse_rss_date(pubdate), + }) + await asyncio.sleep(0.2) + except Exception as e: + logger.debug("rss.fetch.err", source=source_name, error=str(e)) + logger.info("rss.crawled", sources=len(RSS_SOURCES), items=len(news)) + return news + +# ── 비주식 뉴스 필터 ─────────────────────────────────────── +# 명백한 비주식 카테고리 prefix → 수집 차단 +NOISE_PREFIXES = ( + # 도서/문화 + "[책마을]", "[책꽂이]", "[책&생각]", "[책]", "[리뷰]", "[영화]", + "[공연]", "[전시]", "[음악]", "[연예]", + # 의견/사설 + "[사설]", "[칼럼]", "[기고]", "[오피니언]", "[기자수첩]", "[데스크칼럼]", + "[Why]", "[취재일기]", "[현장에서]", "[광화문]", "[로터리]", + # 인사/사회 + "[부고]", "[인사]", "[동정]", "[포토]", "[화보]", "[지면]", "[그래픽]", + # 라이프 + "[건강]", "[푸드]", "[여행]", "[패션]", "[뷰티]", "[자동차]", "[취미]", + "[운세]", "[날씨]", + # 스포츠 + "[스포츠]", "[야구]", "[축구]", "[농구]", "[골프]", + # 광고성/이벤트 + "[보도자료]", "[알림]", "[이벤트]", "[알립니다]", +) + +# 종목/시장과 무관한 라이프스타일·클릭베이트 키워드 (제목에 등장하면 차단) +NOISE_KEYWORDS = ( + # 라이프/소비 + "이직", "퇴사", "신의 직장", "마통", "돈복사", "전기료 폭탄", + "세탁기", "건조기", "에어컨", "다이어트", "맛집", "할인", + "결혼", "이혼", "연애", "임신", "출산", "육아", + # 사회/사건사고 (기업이 부수적으로 언급되는 경우 多) + "성폭행", "성추행", "음주운전", "마약", "도박", "사기", + "교통사고", "화재", "살인", "강도", + # 정치 (종목 영향 적은 일반 정치) + "선거", "투표", "여론조사", "지지율", "총선", "대선", + # 라이프스타일 클릭베이트 + "충격", "발칵", "경악", "헉", "화들짝", "깜짝", "대체", +) +# 클릭베이트: 제목이 큰따옴표·작은따옴표로 시작하면 의심 +CLICKBAIT_QUOTE_RE = re.compile(r'^[\s]*[\"“‘’\']') +# 종목 매칭 0건일 때 시장 관련성 판정용 키워드 +MARKET_KEYWORDS = ( + # 지수/시장 + "코스피", "코스닥", "KOSPI", "KOSDAQ", "나스닥", "다우", "S&P", "증시", + "지수", "시장", "장세", "장중", "거래소", + # 거시지표 + "환율", "금리", "원달러", "원화", "달러", "엔화", "위안", "유가", + "금값", "은값", "원유", "WTI", "브렌트", "채권", "국채", "회사채", + "인플레이션", "물가", "CPI", "PPI", "성장률", "GDP", "경기", "경제", + # 수급 + "외국인", "외인", "기관", "개인투자자", "프로그램매매", "공매도", + "신용잔고", "거래대금", "거래량", + # 종목/거래 + "주식", "주가", "종목", "매수", "매도", "매매", "투자", "테마주", "관련주", + "급등", "급락", "상승", "하락", "신고가", "신저가", "호재", "악재", + # 펀드/상품 + "펀드", "ETF", "ETN", "선물", "옵션", "파생", "리츠", + # 기업이벤트 + "상장", "상폐", "공모", "IPO", "유상증자", "무상증자", "배당", "자사주", + "M&A", "인수", "합병", "분할", "지분", "스톡옵션", + # 실적/재무 + "실적", "영업이익", "매출", "순이익", "EBITDA", "ROE", "PER", "PBR", + "수주", "계약", "수출", "공시", + # 산업/섹터 + "산업", "섹터", "반도체", "바이오", "2차전지", "배터리", "전기차", + "AI", "로봇", "방산", "조선", "건설", "유통", "금융", "은행", "보험", + "증권", "통신", "에너지", "철강", "화학", "정유" +) + +def _is_noise_title(title: str) -> bool: + if not title: + return True + if any(title.startswith(p) for p in NOISE_PREFIXES): + return True + # 클릭베이트: 따옴표 시작 + 시장 키워드 부재 시 차단 + if CLICKBAIT_QUOTE_RE.match(title) and not any(kw in title for kw in MARKET_KEYWORDS): + return True + # 라이프스타일/사건사고 키워드 (제목에서 검사) + if any(nk in title for nk in NOISE_KEYWORDS): + return True + return False + +def _has_market_relevance(text: str) -> bool: + """종목 매칭이 없을 때만 호출. 시장 키워드 ≥2개 매칭 시 통과 (broad 1개로는 부족)""" + if not text: + return False + matches = sum(1 for kw in MARKET_KEYWORDS if kw in text) + return matches >= 2 + +# ── 파이프라인 ───────────────────────────────────────────── +_corp_cache: dict = {} +async def _corp_name(code: str) -> str: + if code in _corp_cache: + return _corp_cache[code] + nm = code + try: + row = await pg_pool.fetchrow( + "SELECT corp_name FROM dart_corps WHERE stock_code=$1", code) + if row and row["corp_name"]: + nm = row["corp_name"] + except Exception: + pass + _corp_cache[code] = nm + return nm + + +async def pipeline(item, client): + try: + # 0. 비주식 카테고리 prefix 차단 (바른API 호출 전) + if _is_noise_title(item.get("title", "")): + return "noise" + + # 1. 바른API + br = await client.post(f"{BAREUN_URL}/analyze", json={ + "title": item["title"], "content": item.get("content",""), + "url": item.get("url",""), "source": item.get("source",""), + "published_at": item.get("published_at","")}, timeout=30) + bd = br.json() + if bd.get("is_duplicate"): return "dup" + + data = {**item, "hash": bd.get("hash",""), "stocks": bd.get("stocks",[]), + "keywords": bd.get("keywords",[]), "filtered_text": bd.get("filtered_text","")} + + # raw 백필: 종목코드가 확정된 뉴스는 종목명 인식 실패해도 보존 (관련성 과필터 완화) + kc = item.get("stock_code") + if kc and not any(st.get("code") == kc for st in data["stocks"]): + data["stocks"] = [{"name": await _corp_name(kc), "code": kc}] + data["stocks"] + + # 1.5. 종목 매칭 + 시장 키워드 둘 다 없으면 스킵 (LLM 비용 절감) + if not data["stocks"]: + full_text = (item.get("title","") or "") + " " + (item.get("content","") or "") + if not _has_market_relevance(full_text): + return "off_topic" + + # 2. Ollama 임베딩 + er = await client.post(f"{OLLAMA_URL}/api/embeddings", + json={"model":"bge-m3","prompt": data["filtered_text"] or data["title"]}, timeout=60) + emb = er.json().get("embedding") + if not emb: return "no_embed" + + # 3. Qdrant 유사도 + try: + sr = await client.post(f"{QDRANT_URL}/collections/news_vectors/points/search", + json={"vector":emb,"limit":6,"score_threshold":0.80,"with_payload":True}, timeout=15) + hits = sr.json().get("result",[]) + if any(h["score"]>=0.92 for h in hits): return "sim_dup" # ≥0.99 근접중복도 차단 + except: hits = [] + + # 3.5. RAG 컨텍스트: 유사 과거뉴스 + 종목 재무·추세·점수 (버핏 판단 근거 주입) + ctx = [] + rel = [h for h in hits if 0.80 <= h.get("score", 0) < 0.92][:4] + if rel: + ctx.append("· 유사 과거뉴스:") + for h in rel: + p = h.get("payload", {}) + ctx.append(f" - {(p.get('title') or '')[:55]} → " + f"{p.get('sentiment','?')}/강도{p.get('intensity','?')}") + focus = data["stocks"][0] if data["stocks"] else None + if focus: + g = lambda v: "-" if v is None else v + try: + fin = await pg_pool.fetchrow(""" + SELECT ROUND(roe::numeric,1) roe, ROUND(operating_margin::numeric,1) om, + ROUND(debt_ratio::numeric,1) dr, ROUND(fcf_ratio::numeric,2) fcf, + ROUND(revenue_growth::numeric,1) rg + FROM dart_financials WHERE stock_code=$1 AND reprt_code='11011' + ORDER BY bsns_year DESC LIMIT 1""", focus["code"]) + if fin: + ctx.append(f"· {focus['name']} 재무: ROE {g(fin['roe'])}% " + f"영업이익률 {g(fin['om'])}% 부채비율 {g(fin['dr'])}% " + f"FCF {g(fin['fcf'])} 매출성장 {g(fin['rg'])}%") + his = await pg_pool.fetchrow(""" + SELECT COUNT(*) FILTER (WHERE sentiment='호재') pos, + COUNT(*) FILTER (WHERE sentiment='악재') neg, + ROUND(AVG(intensity)::numeric,1) ai, + MODE() WITHIN GROUP (ORDER BY catalyst) cat + FROM news_analysis + WHERE primary_stock=$1 AND analyzed_at >= NOW()-INTERVAL '14 days' + """, focus["code"]) + if his and (his["pos"] or his["neg"]): + ctx.append(f"· 최근14일 {focus['name']}: 호재 {his['pos']} / " + f"악재 {his['neg']}, 평균강도 {g(his['ai'])}, " + f"주catalyst {his['cat'] or '-'}") + recent = await pg_pool.fetch(""" + SELECT sentiment, intensity, catalyst, left(reason,50) reason + FROM news_analysis + WHERE primary_stock=$1 AND reason != '파싱실패' + AND analyzed_at >= NOW()-INTERVAL '30 days' + ORDER BY analyzed_at DESC LIMIT 2""", focus["code"]) + if recent: + ctx.append(f"· {focus['name']} 최근 분석:") + for r in recent: + ctx.append(f" - {r['sentiment']}/강도{r['intensity']} " + f"[{r['catalyst'] or '-'}] {r['reason']}") + sc = await pg_pool.fetchrow(""" + SELECT ROUND(total_score::numeric,1) total_score, recommendation + FROM stock_scores + WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1""", + focus["code"]) + if sc: + ctx.append(f"· 현재 퀀트: 종합점수 {g(sc['total_score'])} " + f"({sc['recommendation'] or '-'})") + except Exception as e: + logger.debug("rag.ctx_err", code=focus["code"], error=str(e)) + context_block = "\n".join(ctx) + + # 4. Ollama EXAONE 분석 (버핏 관점 강화 프롬프트) + stocks_str = ", ".join([f'{s["name"]}({s["code"]})' for s in data["stocks"][:5]]) + source = item.get("source", "") + content_preview = (data.get("filtered_text") or item.get("content") or "")[:400] + system_prompt = ( + "당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n" + "뉴스 한 건을 읽고 기업 본질가치 관점에서 영향을 평가합니다.\n" + "판단 우선순위: 기업 본질가치(실적·FCF·경쟁우위) > 재무 리스크 > 단기 수급·테마\n\n" + "[sentiment] 호재 / 악재 / 중립 — 본질가치 또는 주가에 미치는 방향\n\n" + "[intensity] 1~5 정수\n" + " 5=상장폐지·대규모 횡령 등 기업 존폐\n" + " 4=연간실적 20% 이상 영향(대형 수주·어닝쇼크 등)\n" + " 3=분기실적에 유의미한 영향\n" + " 2=영업환경·업황 변화(직접 실적 수치 영향은 제한적)\n" + " 1=단순 정보성·정기공시·소폭 코멘트\n\n" + "[catalyst] 반드시 아래 6개 중 정확히 하나만 출력 (다른 단어·복합어·영문·한자 절대 금지):\n" + ' "실적" = 영업이익·매출·순이익 증감, 흑자/적자 전환, 어닝 서프라이즈/쇼크, 실적 가이던스\n' + ' "수주" = 신규 수주·공급계약·납품, M&A, 신사업·증설·신제품 등 성장 동인\n' + ' "배당" = 배당 확대/축소, 자사주 매입·소각, 주주환원 정책\n' + ' "리스크" = 횡령·배임·소송·감사의견거절·부채급증·유상증자(희석)·경영진 돌발사임·규제/제재·상폐위험\n' + ' "모멘텀" = 본질가치 변화 없는 가격 동인(외국인/기관 수급, 목표주가 조정, 테마·정책 기대)\n' + ' "기타" = 위 어디에도 명확히 속하지 않는 정보성 뉴스\n' + " 호재/악재면 catalyst를 '기타'로 두지 말고 실적·수주·배당·리스크·모멘텀 중 가장 가까운 것을 고르세요.\n\n" + "[참고] 블록은 보조 자료일 뿐 — 본 기사 내용으로 판단하고 과거 감성 흐름에 휩쓸리지 마세요.\n" + "동일 내용이 [참고]의 유사 과거뉴스에 반복되면 신규성이 낮으니 intensity를 보수적으로.\n\n" + "반드시 스키마에 맞는 유효한 JSON 객체 하나만 출력. 마크다운·설명문 금지." + ) + user_prompt = ( + f"[출처] {source}\n" + f"[제목] {data['title'][:200]}\n" + f"[내용] {content_preview}\n" + f"[관련종목] {stocks_str or '없음'}\n" + + (f"\n[참고]\n{context_block}\n" if context_block else "") + + "\ninvestment_action 규칙: 호재+intensity≥3→매수관심, 악재+intensity≥3→매도관심, 그 외→관망\n" + "primary_stock은 6자리 숫자코드(시장 전체 뉴스면 빈 문자열), " + "affected_stocks는 영향받는 다른 종목코드 배열, reason은 한 문장 핵심 근거.\n\n" + "JSON 스키마:\n" + '{"sentiment":"호재|악재|중립","intensity":1~5,"primary_stock":"005930",' + '"affected_stocks":["000660"],"reason":"핵심 근거 한 문장",' + '"investment_action":"매수관심|매도관심|관망",' + '"catalyst":"실적|수주|배당|리스크|모멘텀|기타"}\n' + "예시: " + '{"sentiment":"호재","intensity":4,"primary_stock":"005930",' + '"affected_stocks":["000660"],"reason":"HBM 대형 수주 확정으로 연간 반도체 실적 큰 폭 개선 기대",' + '"investment_action":"매수관심","catalyst":"수주"}' + ) + # EXAONE 호출 + JSON 파싱 (실패 시 1회 재시도 → ~15% 유실 감소) + a = None + raw_prev = "" + for attempt in range(2): + msgs = [{"role":"system","content": system_prompt}, + {"role":"user","content": user_prompt}] + if attempt == 1: + msgs += [{"role":"assistant","content": raw_prev[:600]}, + {"role":"user","content": + "위 응답이 유효한 JSON이 아닙니다. 설명·마크다운 없이 " + "스키마에 맞는 JSON 객체 하나만 출력하세요."}] + try: + vr = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={ + "model":"exaone3.5:7.8b","messages": msgs, + "max_tokens":400,"temperature":0.0 if attempt else 0.05}, + timeout=120) + raw_prev = vr.json()["choices"][0]["message"]["content"] + c = raw_prev.replace("```json","").replace("```","").strip() + if not c.startswith("{"): # 앞뒤 설명 제거 + s, e = c.find("{"), c.rfind("}") + if s != -1 and e > s: c = c[s:e+1] + a = json.loads(c) + break + except Exception: + continue + if a is None: + a = {"sentiment":"중립","intensity":0,"primary_stock":"","affected_stocks":[],"reason":"파싱실패","investment_action":"관망"} + + # catalyst 6개 enum 강제 (score-engine CATALYST_WEIGHTS 정합 — 일탈값은 '기타') + if a.get("catalyst") not in ("실적","수주","배당","리스크","모멘텀","기타"): + a["catalyst"] = "기타" + + # 5. Qdrant 저장 + try: + await client.put(f"{QDRANT_URL}/collections/news_vectors/points", json={ + "points":[{"id":random.randint(1,999999999),"vector":emb, + "payload":{"title":data["title"],"hash":data["hash"], + "sentiment":a.get("sentiment",""),"intensity":a.get("intensity",0), + "primary_stock":a.get("primary_stock","")}}]}, timeout=15) + except: pass + + # 6. PostgreSQL 저장 (파라미터 바인딩 — 인젝션·이스케이프 유실 방지) + pub = data.get("published_at") or "" + try: + pub_dt = datetime.fromisoformat(pub.replace("Z", "+00:00")) if pub else datetime.now() + except Exception: + pub_dt = datetime.now() + try: + iv = int(a.get("intensity", 0) or 0) + except Exception: + iv = 0 + s = lambda v: str(v if v is not None else "") + await pg_pool.execute(""" + INSERT INTO news_analysis (title,url,source,published_at,hash,sentiment,intensity, + primary_stock,affected_stocks,reason,investment_action,keywords,stock_names,stock_codes, + catalyst,similar_count,analyzed_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9::jsonb,$10,$11,$12::jsonb,$13::jsonb,$14::jsonb,$15,0,NOW()) + ON CONFLICT (hash) DO NOTHING + """, + s(data.get("title"))[:500], s(data.get("url"))[:500], s(data.get("source"))[:100], + pub_dt, data["hash"], s(a.get("sentiment", "중립"))[:10], iv, + s(a.get("primary_stock"))[:10], json.dumps(a.get("affected_stocks", []), ensure_ascii=False), + s(a.get("reason"))[:500], s(a.get("investment_action", "관망"))[:10], + json.dumps(data.get("keywords", [])[:20], ensure_ascii=False), + json.dumps([s_["name"] for s_ in data.get("stocks", [])], ensure_ascii=False), + json.dumps([s_["code"] for s_ in data.get("stocks", [])], ensure_ascii=False), + s(a.get("catalyst", "기타"))[:20]) + return "ok" + except Exception as e: + logger.warning("pipeline.err", title=item.get("title","")[:50], error=str(e)) + return "error" + +# ── 수집 작업 ────────────────────────────────────────────── +async def job_rss(): + """RSS 28개 소스 수집 (5분마다, 2단계 중복제거)""" + if stats.running: return + stats.running = True + try: + async with httpx.AsyncClient() as c: + news = await crawl_rss_sources(c) + ok = 0 + for item in news: + if await is_dup(item["title"], item.get("url", "")): + stats.duplicates += 1 + continue + stats.collected += 1 + r = await pipeline(item, c) + if r == "ok": + ok += 1 + stats.processed += 1 + elif r == "noise": + stats.noise += 1 + elif r == "off_topic": + stats.off_topic += 1 + stats.last_run = datetime.now().isoformat() + logger.info("job.rss", sources=len(RSS_SOURCES), total=len(news), processed=ok, + duplicates=stats.duplicates) + except Exception as e: + stats.errors += 1 + logger.error("job.rss.err", error=str(e)) + finally: + stats.running = False + +NAVER_FINANCE_URLS = [ + ("https://finance.naver.com/news/mainnews.naver", "네이버금융"), + ("https://finance.naver.com/news/news_list.nhn?mode=RANK&type=now", "네이버금융"), +] + +async def crawl_naver_market(client) -> list: + news = [] + for url, source in NAVER_FINANCE_URLS: + try: + r = await client.get(url, timeout=15, headers=HEADERS) + if r.status_code != 200: + continue + soup = BeautifulSoup(r.text, "html.parser") + anchors = ( + soup.select("ul.newsList li a") or + soup.select(".headline_list li a") or + soup.select("a[href*='news_read']") + ) + for a in anchors[:30]: + title = a.get_text(strip=True) + href = a.get("href", "") + if not href or not title or len(title) < 5: + continue + if href.startswith("/"): + href = f"https://finance.naver.com{href}" + if not is_korean(title): + continue + news.append({"title": title, "url": href, "source": source, + "content": "", "published_at": datetime.now().isoformat()}) + except Exception as e: + logger.debug("naver.market.err", url=url, error=str(e)) + logger.info("naver.market.crawled", items=len(news)) + return news + +async def crawl_naver_stock_news(client, code: str, max_pages: int = 20, sleep_s: float = 0.3) -> list: + """네이버 모바일 종목 뉴스 API (JSON) — 백워드 페이징""" + items = [] + for page in range(1, max_pages + 1): + url = f"https://m.stock.naver.com/api/news/stock/{code}?pageSize=20&page={page}" + try: + r = await client.get(url, headers=HEADERS, timeout=15) + if r.status_code != 200: + break + data = r.json() + if not isinstance(data, list) or not data: + break + page_items = data[0].get("items", []) if isinstance(data[0], dict) else [] + if not page_items: + break + for it in page_items: + title = (it.get("title") or "").strip() + article_id = it.get("articleId") or "" + office_id = it.get("officeId") or "" + if not title or not article_id or not is_korean(title): + continue + url_news = f"https://n.news.naver.com/article/{office_id}/{article_id}" + dt = it.get("datetime") or "" + pub = (f"{dt[:4]}-{dt[4:6]}-{dt[6:8]} {dt[8:10]}:{dt[10:12]}" + if len(dt) >= 12 else "") + items.append({ + "title": title, "url": url_news, + "source": it.get("officeName") or "네이버", + "published_at": pub, + "content": (it.get("body") or "")[:500], + }) + await asyncio.sleep(sleep_s) + except Exception as e: + logger.debug("naver_mobile.err", code=code, page=page, error=str(e)) + break + return items + + +async def save_raw_news(items: list, stock_code: str) -> int: + """raw 뉴스 일괄 저장 (분석 스킵, 빠른 누적용)""" + if not items: return 0 + saved = 0 + async with pg_pool.acquire() as conn: + for it in items: + try: + url = it.get("url", "") + title = it.get("title", "") + if not url or not title: continue + url_hash = hashlib.sha256(url.encode()).hexdigest() + res = await conn.execute(""" + INSERT INTO news_raw (stock_code, title, url, url_hash, source, published_at_text) + VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (url_hash) DO NOTHING + """, stock_code, title, url, url_hash, + it.get("source", ""), it.get("published_at", "")) + if "INSERT 0 1" in res: + saved += 1 + except Exception as e: + logger.debug("raw.save_err", error=str(e)) + return saved + + +async def job_historical_raw(count: int = 0, max_pages: int = 100): + """전체 활성종목 백워드 크롤링 → news_raw에 raw 저장 (LLM 분석 X, 빠름) + count=0 → 활성 종목 전체""" + if stats.running: + logger.warning("historical_raw.skip_running"); return + stats.running = True + try: + async with pg_pool.acquire() as conn: + if count > 0: + rows = await conn.fetch(""" + SELECT stock_code FROM dart_corps WHERE is_active=true + ORDER BY stock_code LIMIT $1 + """, count) + else: + rows = await conn.fetch( + "SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code") + codes = [r["stock_code"] for r in rows] + logger.info("historical_raw.start", n_stocks=len(codes), max_pages=max_pages) + total_saved = 0 + async with httpx.AsyncClient() as c: + for i, code in enumerate(codes): + try: + items = await crawl_naver_stock_news(c, code, max_pages=max_pages) + saved = await save_raw_news(items, code) + total_saved += saved + if (i + 1) % 100 == 0: + logger.info("historical_raw.progress", + done=i+1, of=len(codes), total_saved=total_saved) + await asyncio.sleep(0.4) + except Exception as e: + stats.errors += 1 + logger.warning("historical_raw.stock_err", code=code, error=str(e)) + stats.last_run = datetime.now().isoformat() + logger.info("historical_raw.done", n_stocks=len(codes), total_saved=total_saved) + finally: + stats.running = False + + +async def job_process_raw(batch_size: int = 200): + """news_raw의 미처리 행을 batch_size만큼 EXAONE 분석 → news_analysis로 이동""" + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT id, stock_code, title, url, source, published_at_text + FROM news_raw WHERE processed=false + ORDER BY id DESC LIMIT $1 + """, batch_size) + if not rows: + logger.info("process_raw.empty"); return 0 + ok = 0 + async with httpx.AsyncClient() as c: + for r in rows: + item = { + "title": r["title"], "url": r["url"], + "source": r["source"] or "네이버금융", + "published_at": r["published_at_text"] or "", + "content": "", + "stock_code": r["stock_code"], + } + try: + if await is_dup(item["title"], item["url"]): + stats.duplicates += 1 + else: + stats.collected += 1 + res = await pipeline(item, c) + if res == "ok": ok += 1; stats.processed += 1 + elif res == "noise": stats.noise += 1 + elif res == "off_topic": stats.off_topic += 1 + async with pg_pool.acquire() as conn: + await conn.execute( + "UPDATE news_raw SET processed=true, processed_at=NOW() WHERE id=$1", + r["id"]) + except Exception as e: + stats.errors += 1 + logger.warning("process_raw.err", id=r["id"], error=str(e)) + logger.info("process_raw.batch_done", batch=len(rows), processed_ok=ok) + return ok + + +async def job_historical(count: int = 500, max_pages: int = 20): + """과거 뉴스 백필: stock_scores 점수 매겨진 활성 종목 우선 + 종목당 max_pages 페이지""" + if stats.running: + logger.warning("historical.skip_running") + return + stats.running = True + try: + async with pg_pool.acquire() as conn: + # 우선순위: 최근 점수 매겨진 종목 → 그 외 활성 종목 alphabetical + rows = await conn.fetch(""" + WITH scored AS ( + SELECT DISTINCT stock_code, MAX(score_date) AS last_d + FROM stock_scores GROUP BY stock_code + ), priority AS ( + SELECT d.stock_code, + CASE WHEN s.stock_code IS NOT NULL THEN 0 ELSE 1 END AS p, + COALESCE(s.last_d, '1900-01-01'::date) AS d + FROM dart_corps d LEFT JOIN scored s ON s.stock_code=d.stock_code + WHERE d.is_active=true + ) + SELECT stock_code FROM priority + ORDER BY p ASC, d DESC, stock_code ASC LIMIT $1 + """, count) + codes = [r["stock_code"] for r in rows] + logger.info("historical.start", n_stocks=len(codes), max_pages=max_pages) + ok = 0; total_items = 0 + async with httpx.AsyncClient() as c: + for i, code in enumerate(codes): + try: + items = await crawl_naver_stock_news(c, code, max_pages=max_pages) + total_items += len(items) + for item in items: + if await is_dup(item["title"], item.get("url", "")): + stats.duplicates += 1 + continue + stats.collected += 1 + item["stock_code"] = code + r = await pipeline(item, c) + if r == "ok": + ok += 1; stats.processed += 1 + elif r == "noise": stats.noise += 1 + elif r == "off_topic": stats.off_topic += 1 + if (i + 1) % 50 == 0: + logger.info("historical.progress", done=i+1, of=len(codes), + items=total_items, processed=ok) + await asyncio.sleep(0.5) # 종목 간격 (차단 회피) + except Exception as e: + stats.errors += 1 + logger.warning("historical.stock_err", code=code, error=str(e)) + stats.last_run = datetime.now().isoformat() + logger.info("historical.done", n_stocks=len(codes), items=total_items, processed=ok) + finally: + stats.running = False + + +async def job_market(): + if stats.running: + return + stats.running = True + try: + async with httpx.AsyncClient() as c: + news = await crawl_naver_market(c) + ok = 0 + for item in news: + if await is_dup(item["title"], item.get("url", "")): + stats.duplicates += 1 + continue + stats.collected += 1 + r = await pipeline(item, c) + if r == "ok": + ok += 1 + stats.processed += 1 + elif r == "noise": + stats.noise += 1 + elif r == "off_topic": + stats.off_topic += 1 + stats.last_run = datetime.now().isoformat() + logger.info("job.market", total=len(news), processed=ok, duplicates=stats.duplicates) + except Exception as e: + stats.errors += 1 + logger.error("job.market.err", error=str(e)) + finally: + stats.running = False + +# ── FastAPI ──────────────────────────────────────────────── +app = FastAPI(title="뉴스 수집기 (멀티소스)") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +async def init_news_tables(): + """news_raw 테이블 (수집/분석 분리용 임시 저장소)""" + async with pg_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS news_raw ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10), + title TEXT NOT NULL, + url TEXT NOT NULL, + url_hash VARCHAR(64) UNIQUE, + source VARCHAR(100), + published_at_text VARCHAR(50), + collected_at TIMESTAMP DEFAULT NOW(), + processed BOOLEAN DEFAULT FALSE, + processed_at TIMESTAMP + ) + """) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_news_raw_unprocessed ON news_raw(processed) WHERE processed=false") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_news_raw_stock ON news_raw(stock_code)") + + +@app.on_event("startup") +async def startup(): + global pg_pool, redis_cl + pg_pool = await asyncpg.create_pool(host=PG_HOST,port=PG_PORT,database=PG_DB,user=PG_USER,password=PG_PASS,min_size=2,max_size=5) + redis_cl = aioredis.Redis(host=REDIS_HOST,port=6379,password=REDIS_PASSWORD,db=4,decode_responses=True) + await init_news_tables() + # 평일 RSS: 8-18시 5분마다 + scheduler.add_job(job_rss,"cron",day_of_week="mon-fri",hour="8-18",minute="*/5",id="rss_weekday",replace_existing=True) + # 주말 RSS: 8-22시 15분마다 (누적 학습용) + scheduler.add_job(job_rss,"cron",day_of_week="sat,sun",hour="8-22",minute="*/15",id="rss_weekend",replace_existing=True) + # 평일 마켓 (네이버 금융): 9-17시 10분마다 + scheduler.add_job(job_market,"cron",day_of_week="mon-fri",hour="9-17",minute="*/10",id="market",replace_existing=True) + # raw 뉴스 분석: 24시간 30분마다 batch 200건 처리 (백로그 소화용) + scheduler.add_job(job_process_raw,"cron",hour="*",minute="*/30", + id="process_raw",replace_existing=True,kwargs={"batch_size":200}) + # 매주 일요일 새벽 2시 raw 백필 (전체 활성종목) + scheduler.add_job(job_historical_raw,"cron",day_of_week="sun",hour="2",minute="0", + id="historical_raw_weekly",replace_existing=True, + kwargs={"count":0,"max_pages":50}) + scheduler.start() + logger.info("news-collector.started", sources=len(RSS_SOURCES)) + +@app.on_event("shutdown") +async def shutdown(): + scheduler.shutdown() + if pg_pool: await pg_pool.close() + if redis_cl: await redis_cl.aclose() + +@app.get("/health") +async def health(): + return JSONResponse(content={"status":"ok","collected":stats.collected,"processed":stats.processed, + "duplicates":stats.duplicates,"noise":stats.noise,"off_topic":stats.off_topic, + "errors":stats.errors,"last_run":stats.last_run,"running":stats.running}) + +@app.post("/collect/rss") +async def m_rss(): + asyncio.create_task(job_rss()); return {"status":"started","type":"rss","sources":len(RSS_SOURCES)} + +@app.post("/collect/market") +async def m_market(): + asyncio.create_task(job_market()); return {"status":"started","type":"market"} + +@app.post("/collect/historical") +async def m_historical(count: int = 500, max_pages: int = 20): + asyncio.create_task(job_historical(count, max_pages)) + return {"status":"started","type":"historical","count":count,"max_pages":max_pages} + +@app.post("/collect/historical-raw") +async def m_historical_raw(count: int = 0, max_pages: int = 100): + """전체 활성종목 raw 백필 (LLM 분석 X). count=0 → 전체""" + asyncio.create_task(job_historical_raw(count, max_pages)) + return {"status":"started","type":"historical_raw","count":count or "전체","max_pages":max_pages} + +@app.post("/process/raw") +async def m_process_raw(batch_size: int = 200): + """news_raw 미처리분 batch 분석 → news_analysis로 이동""" + n = await job_process_raw(batch_size) + return {"status":"done","processed":n} + +@app.get("/raw/stats") +async def raw_stats(): + """raw 백필 진행 상태""" + async with pg_pool.acquire() as conn: + s = await conn.fetchrow(""" + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE processed=false) AS unprocessed, + COUNT(*) FILTER (WHERE processed=true) AS processed, + COUNT(DISTINCT stock_code) AS unique_stocks, + MIN(collected_at) AS first_collected, + MAX(collected_at) AS last_collected + FROM news_raw + """) + return dict(s) if s else {} + +@app.get("/sources") +async def list_sources(): + return [{"name": s, "url": u} for s, u in RSS_SOURCES] diff --git a/news-collector/requirements.txt b/news-collector/requirements.txt new file mode 100644 index 0000000..2f2ab80 --- /dev/null +++ b/news-collector/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +httpx==0.27.0 +redis==5.0.4 +asyncpg==0.29.0 +beautifulsoup4==4.12.3 +lxml==5.2.2 +apscheduler==3.10.4 +orjson==3.10.3 +structlog==24.2.0 diff --git a/qdrant-config/config.yaml b/qdrant-config/config.yaml new file mode 100644 index 0000000..4ef3ef9 --- /dev/null +++ b/qdrant-config/config.yaml @@ -0,0 +1,19 @@ +service: + http_port: 6333 + grpc_port: 6334 + max_request_size_mb: 32 + +storage: + performance: + max_search_threads: 4 + max_optimization_threads: 2 + + optimizers: + deleted_threshold: 0.2 + vacuum_min_vector_number: 1000 + default_segment_number: 4 + indexing_threshold_kb: 20000 + flush_interval_sec: 5 + max_optimization_threads: 2 + +log_level: INFO diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2f2ab80 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +httpx==0.27.0 +redis==5.0.4 +asyncpg==0.29.0 +beautifulsoup4==4.12.3 +lxml==5.2.2 +apscheduler==3.10.4 +orjson==3.10.3 +structlog==24.2.0 diff --git a/score-engine/Dockerfile b/score-engine/Dockerfile new file mode 100644 index 0000000..4846597 --- /dev/null +++ b/score-engine/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +EXPOSE 8686 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8686", "--workers", "1", "--log-level", "info"] diff --git a/score-engine/main.py b/score-engine/main.py new file mode 100644 index 0000000..fce0996 --- /dev/null +++ b/score-engine/main.py @@ -0,0 +1,3155 @@ +""" +종목 점수 엔진 + 추천 시스템 (버핏 스타일 가치투자 기반) +- 펀더멘털 점수 (ROE, 영업이익률, 부채비율, PER/PBR) 30% +- 뉴스 감성 점수 25% +- 공시 점수 (DART) 15% +- 기술적 분석 점수 20% +- 가격 모멘텀 점수 10% +- 가치 필터: PER 0~40, 부채비율<80%, 영업이익>0, 시총>100억 +- 매일 장 마감 후 자동 집계 + 텔레그램 알림 +""" +import asyncio, json, os +from datetime import datetime, date, timedelta +from typing import Optional +import asyncpg, httpx, redis.asyncio as aioredis, structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI, Query +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +PG_HOST = os.getenv("POSTGRES_HOST", "postgres") +PG_PORT = int(os.getenv("POSTGRES_PORT", "5432")) +PG_DB = os.getenv("POSTGRES_DB", "trading_ai") +PG_USER = os.getenv("POSTGRES_USER", "kyu") +PG_PASS = os.getenv("POSTGRES_PASSWORD", "") +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +TG_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") +EXAONE_MODEL = os.getenv("EXAONE_MODEL", "exaone3.5:7.8b") + +pg_pool: Optional[asyncpg.Pool] = None +redis_cl: Optional[aioredis.Redis] = None +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + +# ── 텔레그램 알림 ────────────────────────────────────────── + +async def send_telegram(msg: str): + if not TG_TOKEN or not TG_CHAT_ID: + return + try: + async with httpx.AsyncClient() as c: + await c.post( + f"https://api.telegram.org/bot{TG_TOKEN}/sendMessage", + json={"chat_id": TG_CHAT_ID, "text": msg, "parse_mode": "HTML"}, + timeout=10) + except Exception as e: + logger.warning("telegram.err", error=str(e)) + +# ── DB 초기화 ───────────────────────────────────────────── + +async def init_db(): + async with pg_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS stock_scores ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + score_date DATE NOT NULL, + news_positive INTEGER DEFAULT 0, + news_negative INTEGER DEFAULT 0, + news_neutral INTEGER DEFAULT 0, + news_total INTEGER DEFAULT 0, + avg_intensity FLOAT DEFAULT 0, + news_score FLOAT DEFAULT 0, + dart_positive INTEGER DEFAULT 0, + dart_negative INTEGER DEFAULT 0, + dart_score FLOAT DEFAULT 0, + price_change_pct FLOAT DEFAULT 0, + volume_ratio FLOAT DEFAULT 0, + price_score FLOAT DEFAULT 0, + technical_score FLOAT DEFAULT 0, + foreign_score FLOAT DEFAULT 0, + short_score FLOAT DEFAULT 0, + foreign_ratio FLOAT DEFAULT 0, + short_weight FLOAT DEFAULT 0, + total_score FLOAT DEFAULT 0, + recommendation VARCHAR(20) DEFAULT '관망', + top_reasons TEXT DEFAULT '', + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, score_date) + ) + """) + # 기존 테이블에 technical_score 컬럼 추가 (이미 있으면 무시) + try: + await conn.execute( + "ALTER TABLE stock_scores ADD COLUMN technical_score FLOAT DEFAULT 0") + except: pass + for col in ["foreign_score FLOAT DEFAULT 0", "short_score FLOAT DEFAULT 0", + "foreign_ratio FLOAT DEFAULT 0", "short_weight FLOAT DEFAULT 0"]: + try: + await conn.execute(f"ALTER TABLE stock_scores ADD COLUMN {col}") + except: pass + + # H1/H2/H5/M2: 추세·DCF·이익품질·포지션사이징 컬럼 + for col in ["trend_score FLOAT DEFAULT 0", + "intrinsic_value BIGINT DEFAULT 0", + "margin_of_safety FLOAT DEFAULT 0", + "earnings_quality FLOAT DEFAULT 0", + "position_size_pct FLOAT DEFAULT 0", + "volatility_60d FLOAT DEFAULT 0", + "market_regime_adj FLOAT DEFAULT 0", + "sector VARCHAR(40) DEFAULT ''", + "magic_score FLOAT DEFAULT 0", + "f_score INTEGER DEFAULT 0", + "roc_pct FLOAT DEFAULT 0", + "earnings_yield_pct FLOAT DEFAULT 0", + "altman_z FLOAT DEFAULT 0", + "peg FLOAT DEFAULT 0", + "momentum_pct FLOAT DEFAULT 0", + "beneish_score FLOAT DEFAULT 0", + "signals JSONB DEFAULT '{}'::jsonb", + "buy_votes INTEGER DEFAULT 0", + "sell_votes INTEGER DEFAULT 0", + "gpa_pct FLOAT DEFAULT 0", + "g_score INTEGER DEFAULT 0", + "amihud_illiq FLOAT DEFAULT 0", + "market_beta FLOAT DEFAULT 0"]: + try: + await conn.execute(f"ALTER TABLE stock_scores ADD COLUMN {col}") + except: pass + + await conn.execute("CREATE INDEX IF NOT EXISTS idx_score_date ON stock_scores(score_date DESC)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_score_total ON stock_scores(total_score DESC)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_score_code ON stock_scores(stock_code)") + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS stock_recommendations ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + recommendation VARCHAR(20) NOT NULL, + total_score FLOAT NOT NULL, + news_score FLOAT DEFAULT 0, + dart_score FLOAT DEFAULT 0, + price_score FLOAT DEFAULT 0, + technical_score FLOAT DEFAULT 0, + top_reasons TEXT DEFAULT '', + recommended_at TIMESTAMP DEFAULT NOW() + ) + """) + try: + await conn.execute( + "ALTER TABLE stock_recommendations ADD COLUMN technical_score FLOAT DEFAULT 0") + except: pass + + # RAG + EXAONE 심층분석 리포트 저장 + await conn.execute(""" + CREATE TABLE IF NOT EXISTS deep_analysis ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + analysis_date DATE NOT NULL, + recommendation VARCHAR(20) DEFAULT '중립', + conviction INTEGER DEFAULT 0, + target_price BIGINT DEFAULT 0, + stop_loss BIGINT DEFAULT 0, + thesis TEXT DEFAULT '', + report JSONB DEFAULT '{}'::jsonb, + rag_context TEXT DEFAULT '', + quant_score FLOAT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, analysis_date) + ) + """) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_deep_date ON deep_analysis(analysis_date DESC)") + + await conn.execute(""" + CREATE TABLE IF NOT EXISTS recommendation_performance ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + recommendation VARCHAR(20) NOT NULL, + total_score FLOAT NOT NULL, + entry_price BIGINT DEFAULT 0, + price_7d BIGINT DEFAULT 0, + price_30d BIGINT DEFAULT 0, + return_7d FLOAT DEFAULT NULL, + return_30d FLOAT DEFAULT NULL, + rec_date DATE DEFAULT CURRENT_DATE, + recommended_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(stock_code, rec_date) + ) + """) + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_perf_code ON recommendation_performance(stock_code)") + await conn.execute( + "CREATE INDEX IF NOT EXISTS idx_perf_date ON recommendation_performance(rec_date DESC)") + + # M5: 벤치마크 대비 알파 추적 + for col in ["kospi_return_7d FLOAT DEFAULT NULL", + "kospi_return_30d FLOAT DEFAULT NULL", + "alpha_7d FLOAT DEFAULT NULL", + "alpha_30d FLOAT DEFAULT NULL"]: + try: + await conn.execute(f"ALTER TABLE recommendation_performance ADD COLUMN {col}") + except: pass + + # H3: 시장 레짐 데이터 (KOSPI 200MA/VKOSPI/금리) + await conn.execute(""" + CREATE TABLE IF NOT EXISTS market_regime ( + dt DATE PRIMARY KEY, + kospi_close FLOAT DEFAULT 0, + kospi_ma200 FLOAT DEFAULT 0, + kospi_above_ma BOOLEAN DEFAULT FALSE, + vkospi FLOAT DEFAULT 0, + regime VARCHAR(20) DEFAULT '중립', + regime_adj FLOAT DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() + ) + """) + + # H4: 섹터 컬럼 (dart_corps에 추가) + try: + await conn.execute("ALTER TABLE dart_corps ADD COLUMN sector VARCHAR(40) DEFAULT ''") + except: pass + + # 공식별 가중치 학습 결과 저장 + await conn.execute(""" + CREATE TABLE IF NOT EXISTS weight_config ( + config_date DATE PRIMARY KEY, + weights JSONB NOT NULL, + period_days INTEGER DEFAULT 0, + sample_size INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() + ) + """) + + logger.info("score.db.initialized") + +async def get_current_price(code: str) -> int: + """Redis → DB 순서로 현재가 조회""" + if redis_cl: + try: + c = await redis_cl.get(f"price:{code}") + if c: + return int(json.loads(c).get("price") or 0) + except: pass + async with pg_pool.acquire() as conn: + try: + row = await conn.fetchrow( + "SELECT close_price FROM stock_ohlcv WHERE stock_code=$1 ORDER BY dt DESC LIMIT 1", code) + if row and row["close_price"]: + return int(row["close_price"]) + except: pass + return 0 + +HEALTH_SERVICES = { + "news-collector": "http://news-collector:8787/health", + "kis-api": "http://kis-api:8585/health", + "ta-engine": "http://ta-engine:8484/health", + "dart-collector": "http://dart-collector:8888/health", + "bareunaapi": "http://bareunaapi:5757/health", + "us-market": "http://us-market:8383/health", + "graph-engine": "http://graph-engine:9090/health", +} + +async def health_check_services(): + """전체 서비스 헬스체크 → 2회 연속 실패 시 텔레그램 알림 (1시간 쿨다운)""" + failed = [] + async with httpx.AsyncClient(timeout=8) as client: + for name, url in HEALTH_SERVICES.items(): + try: + r = await client.get(url) + if r.status_code != 200: + failed.append(name) + except: + failed.append(name) + + for svc in failed: + try: + if redis_cl and await redis_cl.get(f"health_alert:{svc}"): + continue # 1시간 쿨다운 중 + # 1회 실패는 fail_count 증가만 (재시작 중 오탐 방지) + fail_key = f"health_fail:{svc}" + count = int(await redis_cl.get(fail_key) or 0) + 1 if redis_cl else 1 + if redis_cl: + await redis_cl.setex(fail_key, 900, str(count)) # 15분 카운터 + if count < 2: + logger.warning("healthcheck.first_fail", service=svc) + continue # 첫 번째 실패는 알림 보류 + except: pass + + await send_telegram( + f"⚠️ 서비스 장애 감지\n" + f"서비스: {svc}\n" + f"시각: {datetime.now().strftime('%m/%d %H:%M')}\n" + f"! docker logs trading-{svc} --tail 30" + ) + try: + if redis_cl: + await redis_cl.setex(f"health_alert:{svc}", 3600, "1") + await redis_cl.delete(f"health_fail:{svc}") + except: pass + if failed: + logger.warning("healthcheck.failed", services=failed) + +async def _kospi_return_between(conn, start_date: date, end_date: date) -> Optional[float]: + """KOSPI 두 날짜 사이 수익률 (%) — stock_ohlcv에 'KOSPI' 코드 필요""" + rows = await conn.fetch(""" + SELECT close_price FROM stock_ohlcv + WHERE stock_code='KOSPI' AND dt IN ($1, $2) + ORDER BY dt + """, start_date, end_date) + if len(rows) < 2: + return None + s, e = float(rows[0]["close_price"]), float(rows[1]["close_price"]) + if s <= 0: + return None + return (e - s) / s * 100 + + +async def update_performance_prices(): + """추천 7일/30일 후 수익률 + KOSPI 대비 알파""" + async with pg_pool.acquire() as conn: + rows_7d = await conn.fetch(""" + SELECT id, stock_code, entry_price, rec_date FROM recommendation_performance + WHERE price_7d = 0 AND entry_price > 0 + AND rec_date <= CURRENT_DATE - 7 AND rec_date >= CURRENT_DATE - 60 + """) + for row in rows_7d: + price = await get_current_price(row["stock_code"]) + if price > 0: + ret = (price - row["entry_price"]) / row["entry_price"] * 100 + kospi_ret = await _kospi_return_between( + conn, row["rec_date"], row["rec_date"] + timedelta(days=7)) + alpha = (ret - kospi_ret) if kospi_ret is not None else None + await conn.execute(""" + UPDATE recommendation_performance + SET price_7d=$1, return_7d=$2, kospi_return_7d=$3, alpha_7d=$4, updated_at=NOW() + WHERE id=$5 + """, price, ret, kospi_ret, alpha, row["id"]) + rows_30d = await conn.fetch(""" + SELECT id, stock_code, entry_price, rec_date FROM recommendation_performance + WHERE price_30d = 0 AND entry_price > 0 + AND rec_date <= CURRENT_DATE - 30 AND rec_date >= CURRENT_DATE - 120 + """) + for row in rows_30d: + price = await get_current_price(row["stock_code"]) + if price > 0: + ret = (price - row["entry_price"]) / row["entry_price"] * 100 + kospi_ret = await _kospi_return_between( + conn, row["rec_date"], row["rec_date"] + timedelta(days=30)) + alpha = (ret - kospi_ret) if kospi_ret is not None else None + await conn.execute(""" + UPDATE recommendation_performance + SET price_30d=$1, return_30d=$2, kospi_return_30d=$3, alpha_30d=$4, updated_at=NOW() + WHERE id=$5 + """, price, ret, kospi_ret, alpha, row["id"]) + logger.info("performance.updated", rows_7d=len(rows_7d), rows_30d=len(rows_30d)) + +def get_recommendation(score: float, buy_votes: int = 0, sell_votes: int = 0) -> str: + """ + 임계값 + 다수공식 동의 강제 + - 강력매수: 점수 ≥70 AND 6공식 중 ≥3 매수 동의 + - 매수관심: 점수 ≥40 AND 매수≥1 AND 매도<2 + - 강력매도: 점수 ≤-60 + - 매도관심: 점수 ≤-30 OR 매도≥3 + - 그 외: 관망 + """ + if score >= 70 and buy_votes >= 3: + return "강력매수" + if score >= 40 and buy_votes >= 1 and sell_votes < 2: + return "매수관심" + if score <= -60 or sell_votes >= 4: + return "강력매도" + if score <= -30 or sell_votes >= 3: + return "매도관심" + return "관망" + +def calc_fundamental_score(fin: dict, per: float, pbr: float) -> tuple[float, list[str]]: + """ + 버핏 스타일 펀더멘털 점수 (-100 ~ +100) + ROE, 영업이익률, 부채비율, 매출성장률, PER/PBR 종합 + returns: (score, reasons) + """ + if not fin: + return 0.0, [] + + score = 0.0 + reasons = [] + + roe = fin.get("roe", 0.0) + op_margin = fin.get("operating_margin", 0.0) + debt_ratio = fin.get("debt_ratio", 100.0) + rev_growth = fin.get("revenue_growth", 0.0) + net_margin = fin.get("net_margin", 0.0) + fcf_ratio = fin.get("fcf_ratio", 0.0) + op_profit = fin.get("operating_profit", 0) + revenue = fin.get("revenue", 0) + + # 영업이익 적자 → 가치투자 대상 아님 (강한 패널티) + if op_profit <= 0 or revenue <= 0: + return -50.0, ["영업적자 종목 제외"] + + # ROE 점수 (버핏: ROE>15% 선호) - 30점 배분 + if roe >= 20: score += 30; reasons.append(f"ROE {roe:.1f}% (우수)") + elif roe >= 15: score += 20; reasons.append(f"ROE {roe:.1f}% (양호)") + elif roe >= 10: score += 10; reasons.append(f"ROE {roe:.1f}% (보통)") + elif roe >= 5: score += 0 + elif roe >= 0: score -= 10 + else: score -= 30; reasons.append(f"ROE {roe:.1f}% (적자)") + + # 영업이익률 점수 - 20점 배분 + if op_margin >= 20: score += 20; reasons.append(f"영업이익률 {op_margin:.1f}% (우수)") + elif op_margin >= 10: score += 12 + elif op_margin >= 5: score += 5 + elif op_margin > 0: score += 0 + else: score -= 20 + + # 부채비율 점수 (낮을수록 좋음) - 20점 배분 + if debt_ratio <= 30: score += 20; reasons.append(f"부채비율 {debt_ratio:.0f}% (건전)") + elif debt_ratio <= 50: score += 12 + elif debt_ratio <= 70: score += 5 + elif debt_ratio <= 80: score += 0 + else: score -= 15; reasons.append(f"부채비율 {debt_ratio:.0f}% (위험)") + + # 매출 성장률 - 15점 배분 + if rev_growth >= 20: score += 15; reasons.append(f"매출성장 {rev_growth:.1f}%") + elif rev_growth >= 10: score += 10 + elif rev_growth >= 0: score += 3 + else: score -= 10; reasons.append(f"매출감소 {rev_growth:.1f}%") + + # PER 밸류에이션 - 15점 배분 (0은 데이터 없음으로 중립) + if 0 < per <= 10: score += 15; reasons.append(f"PER {per:.1f} (저평가)") + elif 0 < per <= 15: score += 10 + elif 0 < per <= 25: score += 5 + elif 0 < per <= 40: score += 0 + elif per > 40: score -= 15; reasons.append(f"PER {per:.1f} (고평가)") + + # PBR - 보조 (자산가치) + if 0 < pbr <= 1.0: score += 5; reasons.append(f"PBR {pbr:.2f} (자산저평가)") + elif 0 < pbr <= 2.0: score += 2 + elif pbr > 5.0: score -= 5 + + # FCF - 현금창출력 + fcf_ratio = fin.get("fcf_ratio", 0.0) + if fcf_ratio >= 10: score += 5; reasons.append(f"FCF {fcf_ratio:.1f}% (우수)") + elif fcf_ratio >= 5: score += 2 + elif fcf_ratio < 0: score -= 5 + + # 배당 점수 (버핏: 꾸준한 배당 선호) + dps = fin.get("dps") or 0 + dps_prev = fin.get("dps_prev") or 0 + div_yield = fin.get("dividend_yield") or 0.0 + if dps > 0: + score += 5 + if dps > dps_prev > 0: + score += 5; reasons.append(f"배당성장 {dps:,}원→{dps_prev:,}원") + elif div_yield >= 3.0: + reasons.append(f"배당수익률 {div_yield:.1f}%") + + return max(-100.0, min(100.0, score)), reasons + + +def calc_foreign_score(foreign_data: list) -> tuple[float, str]: + """외국인 수급 점수 (-100~+100)""" + if not foreign_data or len(foreign_data) < 2: + return 0.0, "" + score = 0.0 + reason = "" + recent = foreign_data[:5] + net_changes = [r.get("change_qty", 0) for r in recent] + buy_days = sum(1 for c in net_changes if c > 0) + sell_days = sum(1 for c in net_changes if c < 0) + # 매수/매도 연속성 점수 (±40) + if buy_days >= 4: + score += 40; reason = f"외국인 {buy_days}일 순매수" + elif buy_days >= 3: + score += 20; reason = f"외국인 순매수 우세" + elif sell_days >= 4: + score -= 40; reason = f"외국인 {sell_days}일 순매도" + elif sell_days >= 3: + score -= 20 + # 보유비중 변화 점수 (±40) + cur_ratio = foreign_data[0].get("hold_ratio", 0) + old_ratio = foreign_data[min(4, len(foreign_data)-1)].get("hold_ratio", cur_ratio) + delta = cur_ratio - old_ratio + if delta > 1.5: score += 40; reason = (reason or "") + f" 비중+{delta:.1f}%p" + elif delta > 0.5: score += 20 + elif delta > 0.1: score += 8 + elif delta < -1.5: score -= 40; reason = (reason or "") + f" 비중{delta:.1f}%p" + elif delta < -0.5: score -= 20 + elif delta < -0.1: score -= 8 + # 절대 보유비중 (±20) - 40% 이상 외국인 보유 = 우량주 신호 + if cur_ratio >= 40: score += 20 + elif cur_ratio >= 25: score += 10 + elif cur_ratio <= 5: score -= 10 + return max(-100.0, min(100.0, score)), reason + + +def calc_short_score(short_data: list) -> tuple[float, str]: + """공매도 점수 (-100~+100), 공매도 많을수록 패널티""" + if not short_data: + return 0.0, "" + score = 0.0 + reason = "" + r0 = short_data[0] + weight = r0.get("trade_weight", 0) # 공매도 거래비중 % + balance = r0.get("short_balance_qty", 0) + # 거래비중 패널티 + if weight < 1.0: score += 20 + elif weight < 2.0: score += 5 + elif weight < 5.0: score -= 15 + elif weight < 10.0: score -= 35; reason = f"공매도비중 {weight:.1f}%" + else: score -= 60; reason = f"공매도비중 {weight:.1f}% 위험" + # 잔고 추세 (5일 변화) + if len(short_data) >= 5: + past_bal = short_data[4].get("short_balance_qty", balance) + if past_bal > 0: + bal_chg_pct = (balance - past_bal) / past_bal * 100 + if bal_chg_pct > 20: score -= 20; reason = (reason or "") + " 잔고급증" + elif bal_chg_pct > 5: score -= 10 + elif bal_chg_pct < -20: score += 15 + elif bal_chg_pct < -5: score += 7 + return max(-100.0, min(100.0, score)), reason + + +# ── H1: 5년 재무 추세 점수 ──────────────────────────────── +async def calc_trend_score(conn, stock_code: str) -> tuple[float, str]: + """ + 최근 5년치 사업보고서 ROE/영업이익률의 일관성·추세 점수 (-30~+30) + 버핏: 안정적이고 우상향하는 수익성 선호 + """ + rows = await conn.fetch(""" + SELECT bsns_year, roe, operating_margin + FROM dart_financials + WHERE stock_code=$1 AND reprt_code='11011' AND roe IS NOT NULL + ORDER BY bsns_year DESC LIMIT 5 + """, stock_code) + if len(rows) < 2: + return 0.0, "" + roes = [float(r["roe"]) for r in rows] + ops = [float(r["operating_margin"]) for r in rows] + n = len(roes) + + score, parts = 0.0, [] + # 일관성: ROE 표준편차가 낮을수록 가산 (변동성 적음) + avg_roe = sum(roes) / n + var_roe = sum((r - avg_roe) ** 2 for r in roes) / n + std_roe = var_roe ** 0.5 + if avg_roe > 5 and std_roe < 5: + score += 10; parts.append(f"ROE 일관성(σ={std_roe:.1f})") + # 추세: 최근(roes[0])이 평균보다 크면 우상향 + if roes[0] > avg_roe * 1.05: + score += 10; parts.append("ROE 우상향") + elif roes[0] < avg_roe * 0.85: + score -= 10; parts.append("ROE 둔화") + # 영업이익률 5년 평균 양수면 가산 + avg_op = sum(ops) / n + if avg_op >= 10: + score += 10 + elif avg_op < 0: + score -= 15 + return max(-30.0, min(30.0, score)), " · ".join(parts) + + +# ── H2: 간이 DCF 내재가치 + 안전마진 ────────────────────── +def calc_dcf(fin: dict, market_cap: int, growth: float = 0.05, + discount: float = 0.09, terminal_growth: float = 0.025) -> tuple[int, float]: + """ + 버핏 스타일 간이 DCF + - 영업현금흐름(없으면 영업이익 80%) 5년 성장 후 영구가치 합산 + - 시총 대비 할인율 = 안전마진 + returns: (내재가치, 안전마진_pct) + """ + op_cf = fin.get("operating_cashflow", 0) or int(fin.get("operating_profit", 0) * 0.8) + if op_cf <= 0 or market_cap <= 0: + return 0, 0.0 + # 5년 cash flow projection + pv = 0.0 + cf = float(op_cf) + for t in range(1, 6): + cf = cf * (1 + growth) + pv += cf / ((1 + discount) ** t) + # Terminal value (Gordon growth) + cf_terminal = cf * (1 + terminal_growth) + tv = cf_terminal / (discount - terminal_growth) + pv += tv / ((1 + discount) ** 5) + intrinsic = int(pv) + # 터미널밸류(15.4배)가 대형주에서 폭주 → 내재가치를 [0, 3×시총]으로 제한. + # 마진은 이미 [-100,200] clamp이므로 스코어링 불변, 표기·RAG값만 현실화. + intrinsic = max(0, min(intrinsic, market_cap * 3)) + # 안전마진 (시총 대비) + margin_pct = (intrinsic - market_cap) / market_cap * 100 + return intrinsic, max(-100.0, min(200.0, margin_pct)) + + +def calc_dcf_score(margin_pct: float) -> tuple[float, str]: + """안전마진 → 점수 (-30~+30)""" + if margin_pct >= 50: + return 30.0, f"안전마진 {margin_pct:.0f}% (대폭저평가)" + if margin_pct >= 25: + return 20.0, f"안전마진 {margin_pct:.0f}% (저평가)" + if margin_pct >= 0: + return 10.0, f"안전마진 {margin_pct:.0f}%" + if margin_pct >= -25: + return 0.0, "" + return -15.0, f"안전마진 {margin_pct:.0f}% (고평가)" + + +# ── H5: 이익 품질 (영업현금흐름/영업이익) ───────────────── +def calc_earnings_quality(fin: dict) -> tuple[float, str]: + """영업현금흐름/영업이익 비율 검증, 0.7 미만이면 분식 의심 패널티""" + op_cf = fin.get("operating_cashflow", 0) + op_pf = fin.get("operating_profit", 0) + if op_pf <= 0: + return 0.0, "" + ratio = op_cf / op_pf if op_pf else 0 + if ratio >= 1.0: + return 10.0, f"이익품질 {ratio:.2f}(우수)" + if ratio >= 0.7: + return 0.0, "" + if ratio >= 0: + return -10.0, f"이익품질 {ratio:.2f}(저조)" + return -20.0, f"이익품질 {ratio:.2f}(분식의심)" + + +# ── 그린블라트 매직 포뮬러 (ROC + Earnings Yield) ───────── +def calc_magic_formula(fin: dict, market_cap: int) -> tuple[float, float, float, str]: + """ + 한국 시장 단순화 버전 (현금 데이터 부재로 EBIT/EV 대신 시총+총부채 사용) + - ROC ≈ 영업이익 / 총자산 + - EY ≈ 영업이익 / (시총 + 총부채) + returns: (score 0~30, roc_pct, ey_pct, reason) + """ + op_pf = fin.get("operating_profit", 0) or 0 + ta = fin.get("total_assets", 0) or 0 + tl = fin.get("total_liabilities", 0) or 0 + if op_pf <= 0 or ta <= 0 or market_cap <= 0: + return 0.0, 0.0, 0.0, "" + roc = op_pf / ta * 100 + ev = market_cap + tl + ey = op_pf / ev * 100 if ev > 0 else 0.0 + + score = 0.0 + if roc >= 25: score += 15 + elif roc >= 15: score += 10 + elif roc >= 8: score += 5 + if ey >= 15: score += 15 + elif ey >= 10: score += 10 + elif ey >= 6: score += 5 + reason = "" + if score >= 20: + reason = f"매직포뮬러 ROC {roc:.0f}%·EY {ey:.0f}% (저평가우량)" + elif score >= 10: + reason = f"매직포뮬러 ROC {roc:.0f}%·EY {ey:.0f}%" + return score, round(roc, 2), round(ey, 2), reason + + +# ── 피오트로스키 F-Score (7개 신호) ─────────────────────── +def calc_piotroski_score(curr: dict, prev: dict) -> tuple[int, float, str]: + """ + 9신호 중 데이터 가용 7개로 0~7점 산출 (current_ratio / 신주발행 생략) + 1) ROA>0 2) CFO>0 3) ΔROA>0 4) CFO>NI + 5) Δdebt_ratio<0 6) Δop_margin>0 7) Δasset_turnover>0 + returns: (f_score 0~7, score_adj -15~+15, reason) + """ + if not curr or not prev: + return 0, 0.0, "" + ta_c = curr.get("total_assets", 0) or 0 + ta_p = prev.get("total_assets", 0) or 0 + if ta_c <= 0 or ta_p <= 0: + return 0, 0.0, "" + + ni_c = curr.get("net_income", 0) or 0 + cfo_c = curr.get("operating_cashflow", 0) or 0 + ni_p = prev.get("net_income", 0) or 0 + rev_c = curr.get("revenue", 0) or 0 + rev_p = prev.get("revenue", 0) or 0 + roa_c = ni_c / ta_c + roa_p = ni_p / ta_p + om_c = curr.get("operating_margin", 0) or 0 + om_p = prev.get("operating_margin", 0) or 0 + dr_c = curr.get("debt_ratio", 0) or 0 + dr_p = prev.get("debt_ratio", 0) or 0 + at_c = rev_c / ta_c if ta_c else 0 + at_p = rev_p / ta_p if ta_p else 0 + + f = 0 + if roa_c > 0: f += 1 + if cfo_c > 0: f += 1 + if roa_c > roa_p: f += 1 + if cfo_c > ni_c: f += 1 + if dr_c < dr_p: f += 1 + if om_c > om_p: f += 1 + if at_c > at_p: f += 1 + + if f >= 6: adj, label = 15.0, "F-Score {0}/7 (재무건전)" + elif f == 5: adj, label = 8.0, "F-Score {0}/7" + elif f == 4: adj, label = 3.0, "" + elif f == 3: adj, label = 0.0, "" + else: adj, label = -15.0, "F-Score {0}/7 (가치함정 경고)" + reason = label.format(f) if label else "" + return f, adj, reason + + +# ── 알트만 Z-Score (단순화 — 운전자본·이익잉여금 데이터 부재) ───── +def calc_altman_z(fin: dict, market_cap: int) -> tuple[float, str, str]: + """ + Z'' 비제조업 모델 일부 변형 (가용 변수만 사용) + Z_simple = 6.72*(EBIT/총자산) + 1.05*(시총/총부채) + > 2.6 안전 / 1.1~2.6 회색 / <1.1 부도위험 + returns: (z_score, signal '매수'|'매도'|'관망', reason) + """ + op_pf = fin.get("operating_profit", 0) or 0 + ta = fin.get("total_assets", 0) or 0 + tl = fin.get("total_liabilities", 0) or 0 + if ta <= 0: + return 0.0, "관망", "" + a = op_pf / ta + b = (market_cap / tl) if tl > 0 else 1.0 + z = 6.72 * a + 1.05 * b + if z >= 2.6: + return round(z, 2), "매수", f"Altman Z {z:.1f} (안전)" + if z >= 1.1: + return round(z, 2), "관망", "" + return round(z, 2), "매도", f"Altman Z {z:.1f} (부도위험)" + + +# ── PEG (린치 GARP) ─────────────────────────────────────── +def calc_peg(curr: dict, prev: dict, per: float) -> tuple[float, str, str]: + """ + PEG = PER / 이익성장률(%) — 1.0 이하 저평가 + 이익성장률은 net_income 전년 대비 + """ + if per <= 0 or not curr or not prev: + return 0.0, "관망", "" + ni_c = curr.get("net_income", 0) or 0 + ni_p = prev.get("net_income", 0) or 0 + if ni_p <= 0 or ni_c <= 0: + return 0.0, "관망", "" + growth = (ni_c - ni_p) / ni_p * 100 + if growth <= 0: + return 0.0, "관망", "" + peg = per / growth + if peg <= 0.75: + return round(peg, 2), "매수", f"PEG {peg:.2f} (성장저평가)" + if peg <= 1.5: + return round(peg, 2), "매수", f"PEG {peg:.2f}" + if peg <= 3.0: + return round(peg, 2), "관망", "" + return round(peg, 2), "매도", f"PEG {peg:.1f} (성장 대비 고평가)" + + +# ── 퀄리티+모멘텀 (12-1개월 가격 모멘텀) ────────────────── +async def calc_momentum(conn, stock_code: str) -> tuple[float, str, str]: + """ + AQR 스타일 12-1개월 모멘텀: (P_t-21 / P_t-252) - 1 + 최근 1개월 제외(반전효과 회피)한 11개월 수익률 + """ + rows = await conn.fetch(""" + SELECT close_price, dt FROM stock_ohlcv + WHERE stock_code=$1 ORDER BY dt DESC LIMIT 260 + """, stock_code) + if len(rows) < 200: + return 0.0, "관망", "" + closes = [(r["dt"], float(r["close_price"])) for r in rows if r["close_price"] > 0] + if len(closes) < 200: + return 0.0, "관망", "" + p_recent = closes[20][1] # 1개월(거래일 ~21) 전 + p_year = closes[-1][1] # 약 12개월 전 + if p_year <= 0: + return 0.0, "관망", "" + mom = (p_recent - p_year) / p_year * 100 + if mom >= 30: + return round(mom, 1), "매수", f"모멘텀 +{mom:.0f}% (강세)" + if mom >= 10: + return round(mom, 1), "매수", f"모멘텀 +{mom:.0f}%" + if mom >= -10: + return round(mom, 1), "관망", "" + if mom >= -30: + return round(mom, 1), "매도", f"모멘텀 {mom:.0f}%" + return round(mom, 1), "매도", f"모멘텀 {mom:.0f}% (약세)" + + +# ── Beneish M-Score 단순화 (회계조작 의심도) ────────────── +def calc_beneish_simplified(curr: dict, prev: dict) -> tuple[float, str, str]: + """ + 8변수 다 부재 → 핵심 3개 휴리스틱: + - TATA = (NI - CFO) / 총자산 (발생액/자산 — 클수록 의심) + - SGI = 매출_t / 매출_t-1 (>1.5 + 발생액 클수록 의심) + - 이익품질: CFO/NI < 0.5 → 매도 / >1.0 → 매수 + """ + if not curr: + return 0.0, "관망", "" + ta = curr.get("total_assets", 0) or 0 + ni = curr.get("net_income", 0) or 0 + cfo = curr.get("operating_cashflow", 0) or 0 + rev_c = curr.get("revenue", 0) or 0 + rev_p = (prev or {}).get("revenue", 0) or 0 + if ta <= 0 or ni == 0: + return 0.0, "관망", "" + tata = (ni - cfo) / ta + sgi = rev_c / rev_p if rev_p > 0 else 1.0 + cfo_ni = cfo / ni if ni > 0 else 0 + # 의심도 점수 (0~100, 낮을수록 좋음) + suspicion = 0.0 + if tata > 0.10: suspicion += 40 + elif tata > 0.05: suspicion += 20 + if sgi > 1.5 and tata>0.05: suspicion += 30 + if cfo_ni < 0.5 and ni>0: suspicion += 30 + elif cfo_ni < 0.7: suspicion += 10 + if suspicion >= 50: + return round(suspicion, 1), "매도", f"Beneish 의심도 {suspicion:.0f} (분식의심)" + if cfo_ni > 1.0 and tata < 0.03: + return round(suspicion, 1), "매수", f"Beneish 청정 (CFO/NI {cfo_ni:.2f})" + return round(suspicion, 1), "관망", "" + + +# ── Novy-Marx Profitability (2013): GP/A ───────────────── +def calc_gp_a(fin: dict) -> tuple[float, str, str]: + """ + Novy-Marx (2013): Gross Profit / Total Assets + 데이터 부재(매출원가 없음)로 영업이익 기반 변형: 영업이익/총자산 + > 15% 매수, < 0% 매도, 그 사이 관망 + """ + op_pf = fin.get("operating_profit", 0) or 0 + ta = fin.get("total_assets", 0) or 0 + if ta <= 0: return 0.0, "관망", "" + gpa = op_pf / ta * 100 + if gpa >= 15: return round(gpa, 2), "매수", f"GP/A {gpa:.1f}% (수익성 우수)" + if gpa >= 5: return round(gpa, 2), "관망", "" + if gpa >= 0: return round(gpa, 2), "관망", "" + return round(gpa, 2), "매도", f"GP/A {gpa:.1f}% (수익성 부진)" + + +# ── Mohanram G-Score (2005, 5신호 — R&D/CAPEX/광고 데이터 부재 생략) ─ +async def calc_mohanram_g(conn, stock_code: str, sector: str, fin_curr: dict, fin_prev: dict) -> tuple[int, str, str]: + """ + Mohanram G-Score (2005): 저PB 가치 종목에서 추가 회피 신호 + 원본 8신호 중 R&D/CAPEX/광고 데이터 없어 5신호로 축소: + 1) ROA > 섹터 중앙값 + 2) CFO/총자산 > 섹터 중앙값 + 3) CFO > NI (이익 품질) + 4) ΔROA > 0 + 5) ROA 변동성 < 섹터 중앙값 (5년치 필요 — 부재 시 ROA > 0으로 대체) + """ + if not fin_curr or not sector or sector == "기타": + return 0, "관망", "" + ta_c = fin_curr.get("total_assets", 0) or 0 + if ta_c <= 0: return 0, "관망", "" + ni_c = fin_curr.get("net_income", 0) or 0 + cfo_c = fin_curr.get("operating_cashflow", 0) or 0 + roa_c = ni_c / ta_c + cfoa_c = cfo_c / ta_c + # 섹터 중앙값 fetch + rows = await conn.fetch(""" + SELECT f.net_income, f.operating_cashflow, f.total_assets + FROM dart_corps d + JOIN dart_financials f ON f.stock_code=d.stock_code AND f.reprt_code='11011' + WHERE d.sector=$1 AND d.is_active=true + AND f.bsns_year=(SELECT MAX(bsns_year) FROM dart_financials f2 + WHERE f2.stock_code=d.stock_code AND f2.reprt_code='11011') + AND f.total_assets > 0 + """, sector) + if len(rows) < 5: return 0, "관망", "" + sec_roa = sorted([(r["net_income"] or 0) / (r["total_assets"] or 1) for r in rows]) + sec_cfoa = sorted([(r["operating_cashflow"] or 0) / (r["total_assets"] or 1) for r in rows]) + median_roa = sec_roa[len(sec_roa)//2] + median_cfoa = sec_cfoa[len(sec_cfoa)//2] + + g = 0 + if roa_c > median_roa: g += 1 + if cfoa_c > median_cfoa: g += 1 + if cfo_c > ni_c: g += 1 + if fin_prev: + ta_p = fin_prev.get("total_assets", 0) or 0 + roa_p = (fin_prev.get("net_income", 0) or 0) / ta_p if ta_p else 0 + if roa_c > roa_p: g += 1 + if roa_c > 0: g += 1 # 5신호 변형 (변동성 대체) + + if g >= 4: return g, "매수", f"G-Score {g}/5 (가치+성장 우수)" + if g <= 1: return g, "매도", f"G-Score {g}/5 (가치함정 위험)" + return g, "관망", "" + + +# ── Amihud 비유동성 (2002) ──────────────────────────────── +async def calc_amihud(conn, stock_code: str) -> tuple[float, str, str]: + """ + Amihud (2002): ILLIQ = avg(|return| / 거래대금) × 1e9 + 소형주 비유동성 프리미엄 — 높을수록 알파 잠재력 ↑ but 거래 어려움 + 20일 평균 사용 (1년 미만 데이터에서도 작동) + """ + rows = await conn.fetch(""" + SELECT close_price, volume FROM stock_ohlcv + WHERE stock_code=$1 ORDER BY dt DESC LIMIT 21 + """, stock_code) + if len(rows) < 10: return 0.0, "관망", "" + closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0] + vols = [int(r["volume"] or 0) for r in rows] + if len(closes) < 10: return 0.0, "관망", "" + illiq_vals = [] + for i in range(len(closes) - 1): + ret = abs(closes[i] - closes[i+1]) / closes[i+1] if closes[i+1] > 0 else 0 + trade_amount = closes[i] * vols[i] + if trade_amount > 0: + illiq_vals.append(ret / trade_amount * 1e9) + if not illiq_vals: return 0.0, "관망", "" + illiq = sum(illiq_vals) / len(illiq_vals) + # 한국 시장 분포 기준 임계: > 100 (소형 비유동성), < 1 (대형 유동) + if illiq >= 100: + return round(illiq, 2), "매수", f"Amihud {illiq:.0f} (소형 알파 후보)" + if illiq >= 10: + return round(illiq, 2), "관망", "" + return round(illiq, 2), "관망", "" + + +# ── 시장 베타 (BAB 핵심 — Frazzini-Pedersen 2014) ────────── +async def calc_beta(conn, stock_code: str, days: int = 60) -> tuple[float, str, str]: + """ + 종목 일별 수익률 vs KOSPI 60일 회귀 베타 + BAB(Betting Against Beta) 알파: 저베타 종목이 위험조정 후 우월 + β < 0.7 매수 (저베타 알파), β > 1.5 매도 (고베타 위험), 그 사이 관망 + """ + rows = await conn.fetch(""" + SELECT s.dt, s.close_price AS stk, k.close_price AS kospi + FROM stock_ohlcv s + JOIN stock_ohlcv k ON k.dt=s.dt AND k.stock_code='KOSPI' + WHERE s.stock_code=$1 + ORDER BY s.dt DESC LIMIT $2 + """, stock_code, days + 1) + if len(rows) < 30: return 0.0, "관망", "" + s_rets, k_rets = [], [] + for i in range(len(rows) - 1): + s_now, s_prev = float(rows[i]["stk"]), float(rows[i+1]["stk"]) + k_now, k_prev = float(rows[i]["kospi"]), float(rows[i+1]["kospi"]) + if s_prev > 0 and k_prev > 0: + s_rets.append((s_now - s_prev) / s_prev) + k_rets.append((k_now - k_prev) / k_prev) + if len(s_rets) < 20: return 0.0, "관망", "" + n = len(s_rets) + s_mean = sum(s_rets) / n; k_mean = sum(k_rets) / n + cov = sum((s_rets[i] - s_mean) * (k_rets[i] - k_mean) for i in range(n)) / n + var = sum((k_rets[i] - k_mean) ** 2 for i in range(n)) / n + if var <= 0: return 0.0, "관망", "" + beta = cov / var + if beta < 0.7: return round(beta, 2), "매수", f"베타 {beta:.2f} (저베타 알파)" + if beta > 1.5: return round(beta, 2), "매도", f"베타 {beta:.2f} (고베타 위험)" + return round(beta, 2), "관망", "" + + +# ── 앙상블 보팅 (공식별 신호 다수결) ─────────────────────── +def aggregate_signals(signals: dict) -> tuple[str, dict]: + """ + signals: {공식이름: '매수'/'매도'/'관망'} + returns: (요약문, 카운트 dict) + """ + counts = {"매수": 0, "매도": 0, "관망": 0} + for s in signals.values(): + counts[s] = counts.get(s, 0) + 1 + parts = [] + if counts["매수"] > 0: parts.append(f"매수 {counts['매수']}") + if counts["매도"] > 0: parts.append(f"매도 {counts['매도']}") + summary = "/".join(parts) if parts else "" + return summary, counts + + +# ── M2: 포지션 사이징 (변동성 역가중 + 점수 가중) ───────── +async def calc_position_size(conn, stock_code: str, total_score: float) -> tuple[float, float]: + """ + 추천 비중(%) = base * (50 / 변동성60d) * (점수/100) + base=10%, 최소 1%, 최대 15% + returns: (size_pct, vol_60d) + """ + if total_score < 30: + return 0.0, 0.0 + rows = await conn.fetch(""" + SELECT close_price FROM stock_ohlcv + WHERE stock_code=$1 ORDER BY dt DESC LIMIT 60 + """, stock_code) + if len(rows) < 30: + return 5.0, 0.0 # 기본 5% + closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0] + rets = [(closes[i] - closes[i+1]) / closes[i+1] for i in range(len(closes)-1) + if closes[i+1] > 0] + if not rets: + return 5.0, 0.0 + avg = sum(rets) / len(rets) + var = sum((r - avg) ** 2 for r in rets) / len(rets) + vol = (var ** 0.5) * (252 ** 0.5) * 100 # 연환산 변동성 % + if vol < 5: vol = 5 + base = 10.0 + size = base * (50.0 / vol) * (total_score / 100.0) + return round(max(1.0, min(15.0, size)), 2), round(vol, 2) + + +# ── M4: catalyst 가중치 ─────────────────────────────────── +CATALYST_WEIGHTS = { + "실적": 1.5, "수주": 1.3, "배당": 1.2, "리스크": 1.4, "기타": 1.0, "모멘텀": 0.8, +} + +async def calc_news_score_weighted(conn, stock_code: str, week_ago: date) -> tuple[float, dict]: + """catalyst별 가중치 적용된 뉴스 점수""" + rows = await conn.fetch(""" + SELECT sentiment, intensity, COALESCE(catalyst, '기타') AS catalyst + FROM news_analysis + WHERE primary_stock=$1 AND analyzed_at >= $2 + AND sentiment IN ('호재','악재') + """, stock_code, datetime.combine(week_ago, datetime.min.time())) + if not rows: + return 0.0, {"pos": 0, "neg": 0, "total": 0} + score = 0.0 + pos = neg = 0 + for r in rows: + w = CATALYST_WEIGHTS.get(r["catalyst"], 1.0) + intensity = float(r["intensity"] or 1) + if r["sentiment"] == "호재": + score += intensity * 5 * w + pos += 1 + else: + score -= intensity * 5 * w + neg += 1 + return max(-100.0, min(100.0, score)), {"pos": pos, "neg": neg, "total": len(rows)} + + +# ── H3: KOSPI 200일 데이터 수집 (네이버 finance) ────────── +async def fetch_kospi_ohlcv() -> int: + """네이버 차트 API에서 KOSPI 일봉 ~300일 가져와 stock_ohlcv['KOSPI']에 저장""" + import re + end = datetime.now().strftime("%Y%m%d") + start = (datetime.now() - timedelta(days=300)).strftime("%Y%m%d") + url = (f"https://api.finance.naver.com/siseJson.naver" + f"?symbol=KOSPI&requestType=1&startTime={start}&endTime={end}&timeframe=day") + saved = 0 + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) + text = r.text + # 응답 형식: [[날짜,시가,고가,저가,종가,거래량,외국인소진율], ...] 단 strict JSON 아님 + # 헤더 첫 줄 제거 후 각 row 파싱 + body = text[text.find("[", text.find("[")+1):] # 두 번째 '[' 부터 + rows = re.findall(r"\[([^\[\]]+)\]", body) + async with pg_pool.acquire() as conn: + for row in rows: + parts = [p.strip().strip("'\"") for p in row.split(",")] + if len(parts) < 6: continue + try: + dt_str = parts[0] + if len(dt_str) != 8: continue + dt_d = date(int(dt_str[:4]), int(dt_str[4:6]), int(dt_str[6:8])) + o = int(float(parts[1])); h = int(float(parts[2])) + l = int(float(parts[3])); c = int(float(parts[4])) + v = int(float(parts[5])) + await conn.execute(""" + INSERT INTO stock_ohlcv (stock_code, dt, open_price, high_price, low_price, close_price, volume) + VALUES ('KOSPI', $1, $2, $3, $4, $5, $6) + ON CONFLICT (stock_code, dt) DO UPDATE SET close_price=$5 + """, dt_d, o, h, l, c, v) + saved += 1 + except: pass + except Exception as e: + logger.warning("kospi.fetch_err", error=str(e)) + logger.info("kospi.saved", count=saved) + return saved + + +# ── OHLCV 백필 (네이버 일봉) — 모멘텀/BAB/기술 복구용 ────── +# 키움 ka10005는 최근 30봉·연속조회 미제공이라 252봉 모멘텀 불가. +# 네이버 siseJson은 종목당 ~300+봉 제공(검증) → 이걸로 과거 백필. +async def fetch_naver_ohlcv(conn, code: str, days: int = 400) -> int: + """네이버 siseJson 종목 일봉 → stock_ohlcv. 반환: 저장 봉수""" + import re + end = datetime.now().strftime("%Y%m%d") + start = (datetime.now() - timedelta(days=days)).strftime("%Y%m%d") + url = (f"https://api.finance.naver.com/siseJson.naver" + f"?symbol={code}&requestType=1&startTime={start}" + f"&endTime={end}&timeframe=day") + saved = 0 + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) + text = r.text + body = text[text.find("[", text.find("[") + 1):] # 헤더행 다음부터 + for row in re.findall(r"\[([^\[\]]+)\]", body): + parts = [p.strip().strip("'\"") for p in row.split(",")] + if len(parts) < 6: + continue + dt_str = parts[0] + if len(dt_str) != 8: + continue + try: + dt_d = date(int(dt_str[:4]), int(dt_str[4:6]), int(dt_str[6:8])) + o, h = int(float(parts[1])), int(float(parts[2])) + l, c = int(float(parts[3])), int(float(parts[4])) + v = int(float(parts[5])) + except Exception: + continue + if c <= 0: + continue + await conn.execute(""" + INSERT INTO stock_ohlcv (stock_code, dt, open_price, + high_price, low_price, close_price, volume) + VALUES ($1,$2,$3,$4,$5,$6,$7) + ON CONFLICT (stock_code, dt) DO UPDATE SET + close_price=EXCLUDED.close_price, volume=EXCLUDED.volume + """, code, dt_d, o, h, l, c, v) + saved += 1 + except Exception as e: + logger.debug("naver_ohlcv.err", code=code, error=str(e)) + return saved + + +# ── H3: 시장 레짐 ───────────────────────────────────────── +async def calc_market_regime(conn) -> tuple[str, float]: + """ + KOSPI 종가 vs 200일 이평으로 시장 레짐 판단 + 위면 강세(+5), 아래면 약세(-10), 데이터 없으면 중립 + """ + # KOSPI 인덱스를 stock_ohlcv에 코드 'KOSPI'로 저장한다고 가정 + rows = await conn.fetch(""" + SELECT close_price FROM stock_ohlcv + WHERE stock_code='KOSPI' ORDER BY dt DESC LIMIT 200 + """) + if len(rows) < 100: + return "데이터부족", 0.0 + closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0] + if not closes: + return "데이터부족", 0.0 + cur = closes[0] + ma200 = sum(closes) / len(closes) + if cur > ma200 * 1.02: + return "강세", 5.0 + if cur < ma200 * 0.95: + return "약세", -10.0 + return "중립", 0.0 + + +# ── H4: 섹터 (DART corp 분류 / KRX) ────────────────────── +async def get_stock_sector(conn, stock_code: str) -> str: + """ + 1차: dart_corps.sector 컬럼 (있으면) + 2차: stock_recommendations 같은 테이블 별도 매핑 (미구현 시 '기타') + """ + try: + row = await conn.fetchrow( + "SELECT sector FROM dart_corps WHERE stock_code=$1", stock_code) + if row and row["sector"]: + return row["sector"] + except: pass + return "기타" + + +def is_value_investable(fin: dict, per: float, pbr: float, market_cap: int) -> tuple[bool, str]: + """버핏 기준 투자 가능 여부 필터""" + # 재무 데이터 부재 = 검증 불가 → 가치투자 대상 제외 (관망 처리) + if not fin: + return False, "재무 데이터 부재" + + if fin.get("operating_profit", 0) <= 0: + return False, "영업적자" + if fin.get("revenue", 0) <= 0: + return False, "매출 없음" + if fin.get("debt_ratio", 0) > 85: + return False, f"부채비율 {fin.get('debt_ratio',0):.0f}% 초과" + # PER>60 단독 배제는 폐지(B) — 성장/테마주 구제 위해 호출부에서 + # PEG·12-1모멘텀과 함께 판단 (calculate_daily_scores, 모멘텀 산출 직후) + if market_cap > 0 and market_cap < 10_000_000_000: # 100억 미만 + return False, "시총 100억 미만" + return True, "" + +# ── 일간 점수 산출 ──────────────────────────────────────── + +# ══════════════════════════════════════════════════════════ +# 신규 보조 시그널 (임원매매 / 컨센서스 / 매크로 / 기관 / 밸류 percentile) +# ══════════════════════════════════════════════════════════ + +async def _load_insider_map(conn) -> dict: + """최근 90일 임원·대주주 매매 집계. 종목별 (net_change, buy_cnt, sell_cnt, top_actor)""" + rows = await conn.fetch(""" + SELECT stock_code, + SUM(shares_change) AS net, + SUM(CASE WHEN shares_change > 0 THEN 1 ELSE 0 END) AS buys, + SUM(CASE WHEN shares_change < 0 THEN 1 ELSE 0 END) AS sells, + COUNT(*) AS total + FROM dart_insider_trades + WHERE rcept_dt >= CURRENT_DATE - 90 + GROUP BY stock_code + """) + return {r["stock_code"]: dict(r) for r in rows} + + +def calc_insider_signal(stat: dict) -> tuple[float, str]: + """매수 위주면 +, 매도 위주면 -. 최대 ±15.""" + if not stat: + return 0.0, "" + buys = int(stat["buys"] or 0) + sells = int(stat["sells"] or 0) + if buys + sells == 0: + return 0.0, "" + # 매수/매도 비율 기반 점수 + ratio = (buys - sells) / max(1, buys + sells) # -1 ~ +1 + sig = ratio * 15.0 + # 거래량 가중 (절대수치) — 너무 적으면 신뢰도 ↓ + if buys + sells < 3: + sig *= 0.5 + sig = max(-15.0, min(15.0, sig)) + reason = f"내부자 매수{buys}/매도{sells}" + return sig, reason + + +async def _load_consensus_map(conn) -> dict: + rows = await conn.fetch( + "SELECT stock_code, target_price, recomm_mean FROM analyst_consensus " + "WHERE updated_at >= CURRENT_DATE - 30") + return {r["stock_code"]: dict(r) for r in rows} + + +def calc_consensus_signal(cons: dict, cur_price: float) -> tuple[float, str]: + """목표주가 대비 상승여력 + 매수의견 평균. + 네이버 recomm_mean: 5에 가까울수록 매수, 1에 가까울수록 매도. + 잠재상승률 = (target/cur - 1) * 100""" + if not cons or cur_price <= 0: + return 0.0, "" + tp = float(cons.get("target_price") or 0) + rm = float(cons.get("recomm_mean") or 0) + if tp <= 0 and rm == 0: + return 0.0, "" + sig = 0.0 + reason_parts = [] + if tp > 0: + upside = (tp / cur_price - 1) * 100 # % + # +30% 이상이면 +6, -10% 이하면 -6 + upside_score = max(-6.0, min(6.0, upside / 5.0)) + sig += upside_score + reason_parts.append(f"목표가{int(tp):,}({upside:+.0f}%)") + if rm > 0: + # 5=매수 → +4, 1=매도 → -4 (3.0이 중립 기준) + rm_score = (rm - 3.0) * 2.0 + sig += max(-4.0, min(4.0, rm_score)) + sig = max(-10.0, min(10.0, sig)) + return sig, " ".join(reason_parts) + + +async def _load_macro_state(conn) -> dict: + """최근 5일 vs 그 이전 5일 매크로 변동률""" + rows = await conn.fetch(""" + SELECT indicator, trade_date, value FROM macro_daily + WHERE trade_date >= CURRENT_DATE - 20 + ORDER BY indicator, trade_date DESC + """) + by_ind: dict = {} + for r in rows: + by_ind.setdefault(r["indicator"], []).append(float(r["value"])) + out = {} + for ind, vals in by_ind.items(): + if len(vals) < 6: + continue + recent = sum(vals[:5]) / 5 + prev = sum(vals[5:10]) / max(1, len(vals[5:10])) + chg_pct = (recent / prev - 1) * 100 if prev else 0 + out[ind] = {"current": vals[0], "chg_pct": chg_pct} + return out + + +# 섹터 키워드 → 매크로 영향 베타 +# (usdkrw_beta, kor_10y_beta): 환율 1% 상승 시 점수, 금리 1%p 상승 시 점수 +SECTOR_MACRO_BETA = [ + (["반도체", "전자집적", "전자부품"], 4.0, -2.0), # 수출↑환율호재, 금리↑부담 + (["자동차", "운송장비"], 4.0, -1.0), + (["조선", "선박"], 4.0, -0.5), + (["철강", "1차금속"], 3.0, -0.5), + (["석유", "정제", "원유"], -3.0, 1.0), # 정유: 환율 부담, 금리↑금융수익 + (["은행", "보험", "증권", "금융"], 0.0, 3.0), # 금리↑이자수익 + (["소프트웨어", "정보서비스", "인터넷"], -1.0, -3.0), # 성장주: 금리↑부담 + (["바이오", "생물의약", "의약품"], -1.0, -2.0), + (["건설"], -2.0, -2.0), + (["식품", "음료"], -1.5, 0.0), +] + + +def calc_macro_signal(sector: str, macro: dict) -> tuple[float, str]: + """섹터 매크로 베타 × 최근 5일 매크로 변동률""" + if not macro or not sector: + return 0.0, "" + usdkrw_chg = (macro.get("usdkrw") or {}).get("chg_pct", 0) + kor_10y_chg = (macro.get("kor_10y") or {}).get("chg_pct", 0) + fx_beta, rate_beta = 0.0, 0.0 + matched = False + for kws, fb, rb in SECTOR_MACRO_BETA: + if any(kw in sector for kw in kws): + fx_beta, rate_beta = fb, rb + matched = True + break + if not matched: + return 0.0, "" + sig = fx_beta * (usdkrw_chg / 1.0) + rate_beta * (kor_10y_chg / 1.0) + sig = max(-10.0, min(10.0, sig)) + parts = [] + if abs(fx_beta * usdkrw_chg) > 0.5: + parts.append(f"환율{usdkrw_chg:+.1f}%") + if abs(rate_beta * kor_10y_chg) > 0.5: + parts.append(f"금리{kor_10y_chg:+.1f}%") + return sig, " ".join(parts) + + +async def _load_inst_flow_map(conn) -> dict: + """종목별 최근 5일 기관·외국인 순매수 합계""" + rows = await conn.fetch(""" + SELECT stock_code, + SUM(inst_net) AS inst5d, + SUM(foreign_net) AS for5d, + AVG(close_price)::float AS avg_price + FROM inst_daily_flow + WHERE trade_date >= CURRENT_DATE - 7 + GROUP BY stock_code + """) + return {r["stock_code"]: dict(r) for r in rows} + + +def calc_inst_flow_signal(flow: dict) -> tuple[float, str]: + """기관 5일 순매수가 평균거래량 대비 의미 있으면 + 가산. 외국인과 같은 방향이면 추가 가중.""" + if not flow: + return 0.0, "" + inst5 = int(flow.get("inst5d") or 0) + for5 = int(flow.get("for5d") or 0) + if inst5 == 0 and for5 == 0: + return 0.0, "" + # 기관 시그널: tanh 스케일 (포화) + import math + inst_score = math.tanh(inst5 / 5_000_000) * 6.0 + # 외국인 같은 방향이면 +4, 반대면 -2 + if inst5 * for5 > 0: + inst_score += 4.0 if abs(for5) > 1_000_000 else 2.0 + elif inst5 * for5 < 0: + inst_score -= 2.0 + sig = max(-10.0, min(10.0, inst_score)) + direction = "매수" if inst5 > 0 else "매도" + reason = f"기관 5d {direction}{abs(inst5)//10000:,}만주" + return sig, reason + + +def calc_valuation_percentile(per_history: list, cur_per: float) -> tuple[float, str]: + """과거 PER 분포 대비 현재 위치. 표본 부족 시 0.""" + if not per_history or len(per_history) < 30 or cur_per <= 0: + return 0.0, "" + sorted_h = sorted([p for p in per_history if p > 0]) + if len(sorted_h) < 30: + return 0.0, "" + # percentile rank + below = sum(1 for p in sorted_h if p < cur_per) + pct = below / len(sorted_h) * 100 + # 하위 20% → +10, 하위 40% → +5, 상위 20% → -10 + if pct <= 20: + return 10.0, f"PER 역사적 저평가({pct:.0f}%ile)" + if pct <= 40: + return 5.0, f"PER 저평가({pct:.0f}%ile)" + if pct >= 80: + return -10.0, f"PER 고평가({pct:.0f}%ile)" + if pct >= 60: + return -5.0, f"PER 고평가({pct:.0f}%ile)" + return 0.0, "" + + +async def calculate_daily_scores(): + logger.info("scoring.start") + today = date.today() + week_ago = today - timedelta(days=7) + + strong_buy: list = [] + strong_sell: list = [] + + # H3: KOSPI 일봉 갱신 후 시장 레짐 계산 + await fetch_kospi_ohlcv() + + # 공식별 학습 가중치 로드 (없으면 균등 1.0) + formula_weights = {"magic": 1.0, "fscore": 1.0, "altman": 1.0, + "peg": 1.0, "momentum": 1.0, "beneish": 1.0, + "graph": 1.0} + async with pg_pool.acquire() as conn: + cfg = await conn.fetchrow( + "SELECT weights FROM weight_config ORDER BY config_date DESC LIMIT 1") + if cfg and cfg["weights"]: + try: + w = cfg["weights"] + if isinstance(w, str): + w = json.loads(w) + for k in formula_weights: + if k in w: + formula_weights[k] = float(w[k]) + except Exception as e: + logger.warning("weights.load_err", error=str(e)) + + async with pg_pool.acquire() as conn: + # H3: 시장 레짐 1회 계산 (전 종목 동일 적용) + regime_label, regime_adj = await calc_market_regime(conn) + await conn.execute(""" + INSERT INTO market_regime (dt, regime, regime_adj) + VALUES ($1, $2, $3) + ON CONFLICT (dt) DO UPDATE SET regime=$2, regime_adj=$3 + """, today, regime_label, regime_adj) + logger.info("market.regime", label=regime_label, adj=regime_adj) + + # 신규 보조 시그널 사전 로드 (한 번에 dict로) + insider_map: dict = {} + consensus_map: dict = {} + flow_map: dict = {} + macro_state: dict = {} + try: + insider_map = await _load_insider_map(conn) + consensus_map = await _load_consensus_map(conn) + flow_map = await _load_inst_flow_map(conn) + macro_state = await _load_macro_state(conn) + logger.info("aux_signals.loaded", insider=len(insider_map), + consensus=len(consensus_map), flow=len(flow_map), + macro_inds=len(macro_state)) + except Exception as e: + logger.warning("aux_signals.load_err", error=str(e)) + + # 미국증시 overnight 보정 사전 로드 (us-market 서비스가 채움) + # 시그널 날짜는 미국장 마감 기준이라 한국 today와 1일 차이날 수 있음 + # → 최근 2일 이내 가장 최신 시그널 사용 + us_overnight_map: dict = {} + try: + us_rows = await conn.fetch( + "SELECT DISTINCT ON (kr_code) kr_code, total_adj, contributing_pairs, signal_date " + "FROM us_overnight_signal " + "WHERE signal_date >= $1::date - 2 " + "ORDER BY kr_code, signal_date DESC", today) + for r in us_rows: + cp = r["contributing_pairs"] + if isinstance(cp, str): + try: cp = json.loads(cp) + except: cp = {} + top_str = "" + pairs = (cp or {}).get("pairs", []) if cp else [] + if pairs: + top = max(pairs, key=lambda p: abs(p.get("contribution", 0))) + top_str = (f"{top.get('us','')} " + f"({top.get('pct',0):+.1f}% × β{top.get('beta',1):.1f})") + us_overnight_map[r["kr_code"]] = { + "adj": float(r["total_adj"] or 0), + "top": top_str, + } + logger.info("us_overnight.loaded", count=len(us_overnight_map)) + except Exception as e: + logger.warning("us_overnight.load_err", error=str(e)) + + # GNN 예측 사전 로드 (graph-engine이 매일 16:25 KST 채움) + graph_pred_map: dict = {} + try: + g_rows = await conn.fetch( + "SELECT DISTINCT ON (stock_code) stock_code, pred_return " + "FROM graph_predictions " + "WHERE predict_date >= $1::date - 3 " + "ORDER BY stock_code, predict_date DESC", today) + for r in g_rows: + graph_pred_map[r["stock_code"]] = float(r["pred_return"] or 0) + logger.info("graph_pred.loaded", count=len(graph_pred_map)) + except Exception as e: + logger.warning("graph_pred.load_err", error=str(e)) + + # 7일 뉴스 통계 dict (stock_code → 집계) — 뉴스 없는 종목도 점수화하므로 lookup 방식 + news_rows = await conn.fetch(""" + SELECT primary_stock AS stock, + SUM(CASE WHEN sentiment='호재' THEN 1 ELSE 0 END) AS pos, + SUM(CASE WHEN sentiment='악재' THEN 1 ELSE 0 END) AS neg, + COUNT(*) AS total, + COALESCE(AVG(intensity), 0) AS avg_int, + SUM(CASE WHEN sentiment='호재' THEN intensity ELSE 0 END) AS pos_score, + SUM(CASE WHEN sentiment='악재' THEN intensity ELSE 0 END) AS neg_score + FROM news_analysis + WHERE primary_stock != '' + AND primary_stock NOT IN ('코스피','코스닥','KOSPI','KOSDAQ','없음','') + AND analyzed_at >= $1 + GROUP BY primary_stock + """, datetime.combine(week_ago, datetime.min.time())) + news_stats_map = {r["stock"]: r for r in news_rows} + + # 점수 산출 대상: 활성 종목 전체 (뉴스 유무 무관) + candidate_rows = await conn.fetch(""" + SELECT stock_code FROM dart_corps WHERE is_active=true ORDER BY stock_code + """) + + scored = 0 + for row in candidate_rows: + stock = row["stock_code"] + if not stock or len(stock) > 20: continue + + # 뉴스 점수 (없으면 0) + news_row = news_stats_map.get(stock) + if news_row: + raw_news = (float(news_row["pos_score"] or 0) - float(news_row["neg_score"] or 0)) * 5 + avg_int = float(news_row["avg_int"] or 0) + else: + raw_news = 0.0 + avg_int = 0.0 + news_score = max(-100.0, min(100.0, raw_news)) + + # DART 공시 점수 + dart_rows = await conn.fetch(""" + SELECT sentiment, intensity FROM news_analysis + WHERE source = 'DART공시' AND primary_stock = $1 AND analyzed_at >= $2 + """, stock, datetime.combine(week_ago, datetime.min.time())) + dart_pos = sum(1 for r in dart_rows if r["sentiment"] == "호재") + dart_neg = sum(1 for r in dart_rows if r["sentiment"] == "악재") + dart_score = max(-100.0, min(100.0, (dart_pos - dart_neg) * 15)) + + # 가격/PER/PBR/시총 (Redis price:{code} → fallback: DB stock_prices) + price_score = 0.0 + price_change = 0.0 + has_price = False + per = pbr = market_cap = 0.0 + if redis_cl: + try: + cached = await redis_cl.get(f"price:{stock}") + if cached: + pd = json.loads(cached) + price_change = pd.get("change_pct", 0) + price_score = max(-100.0, min(100.0, price_change * 10)) + per = float(pd.get("per", 0) or 0) + pbr = float(pd.get("pbr", 0) or 0) + market_cap = float(pd.get("market_cap", 0) or 0) + has_price = True + except: pass + + # DB fallback 1: stock_prices (장중 수집 데이터) + if not has_price: + try: + pr = await conn.fetchrow( + "SELECT change_pct, per, pbr, market_cap FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1", + stock) + if pr: + price_change = float(pr["change_pct"] or 0) + price_score = max(-100.0, min(100.0, price_change * 10)) + per = float(pr["per"] or 0) + pbr = float(pr["pbr"] or 0) + market_cap = float(pr["market_cap"] or 0) + has_price = True + except: pass + + # DB fallback 2: stock_ohlcv 최근 종가 (장마감 후 price:{code} TTL 만료 시) + if not has_price: + try: + ov = await conn.fetchrow( + "SELECT close_price, foreign_ratio FROM stock_ohlcv WHERE stock_code=$1 ORDER BY dt DESC LIMIT 1", + stock) + if ov and ov["close_price"] > 0: + # 전일 종가 기반, 당일 변동 미반영 → price_score=0 (중립) + price_change = 0.0 + price_score = 0.0 + has_price = True # 가격 정보는 있음 (변동률만 없음) + except: pass + + # 기술적 점수 (stock_technical 테이블 - Redis TTL/DB 불일치 방지) + technical_score = 0.0 + try: + ta_row = await conn.fetchrow( + "SELECT tech_score FROM stock_technical WHERE stock_code=$1", stock) + if ta_row: + technical_score = float(ta_row["tech_score"] or 0) + except: pass + + # 외국인 수급 점수 (Redis foreign:{code}) + foreign_score = 0.0; foreign_ratio = 0.0; foreign_reason = "" + try: + f_raw = await redis_cl.get(f"foreign:{stock}") + if f_raw: + f_data = json.loads(f_raw) + foreign_score, foreign_reason = calc_foreign_score(f_data) + foreign_ratio = f_data[0].get("hold_ratio", 0) if f_data else 0 + except: pass + + # 공매도 점수 (Redis short:{code}) + short_score = 0.0; short_weight_val = 0.0; short_reason = "" + try: + s_raw = await redis_cl.get(f"short:{stock}") + if s_raw: + s_data = json.loads(s_raw) + short_score, short_reason = calc_short_score(s_data) + short_weight_val = s_data[0].get("trade_weight", 0) if s_data else 0 + except: pass + + # 펀더멘털 점수 (dart_financials - 최신 사업보고서 기준) + fin_row = await conn.fetchrow(""" + SELECT f.roe, f.operating_margin, f.net_margin, f.debt_ratio, + f.fcf_ratio, f.revenue_growth, f.operating_profit, f.revenue, + f.operating_cashflow, f.total_equity, f.net_income, + f.total_assets, f.total_liabilities, + d.dps, d.dps_prev, d.dividend_yield + FROM dart_financials f + LEFT JOIN dart_dividends d ON d.stock_code = f.stock_code + AND d.bsns_year = f.bsns_year + WHERE f.stock_code = $1 + ORDER BY f.bsns_year DESC, f.reprt_code DESC + LIMIT 1 + """, stock) + fin_data = dict(fin_row) if fin_row else {} + # LEFT JOIN으로 NULL 가능 → 숫자 컬럼 일괄 정규화 (None → 0) + for k in ("roe", "operating_margin", "net_margin", "debt_ratio", + "fcf_ratio", "revenue_growth", "operating_profit", "revenue", + "operating_cashflow", "total_equity", "net_income", + "total_assets", "total_liabilities", + "dps", "dps_prev", "dividend_yield"): + if fin_data.get(k) is None: + fin_data[k] = 0 + + # 최근 2년 연간 사업보고서 (F-Score year-over-year + 매직 포뮬러용) + # 분기 보고서가 아닌 연간(11011)을 써야 영업이익/총자산 단위가 일치 + f_score_rows = await conn.fetch(""" + SELECT bsns_year, operating_margin, debt_ratio, revenue, + operating_cashflow, net_income, operating_profit, + total_assets, total_liabilities + FROM dart_financials + WHERE stock_code=$1 AND reprt_code='11011' + ORDER BY bsns_year DESC LIMIT 2 + """, stock) + fin_curr = dict(f_score_rows[0]) if len(f_score_rows) >= 1 else {} + fin_prev = dict(f_score_rows[1]) if len(f_score_rows) >= 2 else {} + + # 버핏 가치 필터 적용 + investable, filter_reason = is_value_investable(fin_data, per, pbr, int(market_cap)) + if not investable: + logger.debug("scoring.filtered", stock=stock, reason=filter_reason) + continue + + fundamental_score, fin_reasons = calc_fundamental_score(fin_data, per, pbr) + + # H1: 5년 추세 점수 + trend_score, trend_reason = await calc_trend_score(conn, stock) + if trend_reason: + fin_reasons.append(trend_reason) + + # H2: DCF 내재가치 + 안전마진 + intrinsic, mos = calc_dcf(fin_data, int(market_cap)) + mos_score, mos_reason = calc_dcf_score(mos) + if mos_reason: + fin_reasons.append(mos_reason) + + # H5: 이익 품질 + eq_score, eq_reason = calc_earnings_quality(fin_data) + if eq_reason: + fin_reasons.append(eq_reason) + + # 매직 포뮬러 (ROC + Earnings Yield) — 연간 사업보고서 기준 + magic_score, roc_pct, ey_pct, magic_reason = calc_magic_formula( + fin_curr or fin_data, int(market_cap)) + if magic_reason: + fin_reasons.append(magic_reason) + + # 피오트로스키 F-Score (가치함정 회피) + f_score, f_score_adj, f_score_reason = calc_piotroski_score(fin_curr, fin_prev) + if f_score_reason: + fin_reasons.append(f_score_reason) + + # 알트만 Z-Score (부도 위험) + altman_z, altman_sig, altman_reason = calc_altman_z(fin_curr or fin_data, int(market_cap)) + if altman_reason: + fin_reasons.append(altman_reason) + + # PEG (린치 GARP) + peg_val, peg_sig, peg_reason = calc_peg(fin_curr, fin_prev, per) + if peg_reason: + fin_reasons.append(peg_reason) + + # 12-1개월 모멘텀 (AQR) + mom_val, mom_sig, mom_reason = await calc_momentum(conn, stock) + if mom_reason: + fin_reasons.append(mom_reason) + + # B: 가치 게이트 완화 — PER>60 단독 배제 폐지. + # 흑자·재무건전(is_value_investable 통과) 종목이 PER만 높을 때 + # PEG≤1.5(이익성장이 배수 정당화) 또는 12-1모멘텀≥10%(추세)면 추천 유지, + # 둘 다 아니면 '성장 미검증 고평가'로 제외(가치함정 회피). + if 0 < per > 60: + peg_ok = 0 < (peg_val or 0) <= 1.5 + mom_ok = (mom_val or 0) >= 10.0 + if not (peg_ok or mom_ok): + logger.debug("scoring.per_gate", stock=stock, + per=round(per, 1), peg=peg_val, mom=mom_val) + continue + fin_reasons.append( + f"PER{per:.0f} 고평가나 " + + (f"PEG{peg_val:.2f}" if peg_ok else f"모멘텀+{mom_val:.0f}%") + + " 성장 구제") + + # Beneish M-Score 단순화 (분식 의심) + beneish_val, beneish_sig, beneish_reason = calc_beneish_simplified(fin_curr, fin_prev) + if beneish_reason: + fin_reasons.append(beneish_reason) + + # H4: 섹터 (G-Score보다 먼저 정의 필요) + sector = await get_stock_sector(conn, stock) + + # Novy-Marx GP/A (2013) — 수익성 + gpa_val, gpa_sig, gpa_reason = calc_gp_a(fin_curr or fin_data) + if gpa_reason: + fin_reasons.append(gpa_reason) + + # Mohanram G-Score (2005) — 가치+성장 회피 + g_val, g_sig, g_reason = await calc_mohanram_g(conn, stock, sector, fin_curr, fin_prev) + if g_reason: + fin_reasons.append(g_reason) + + # Amihud 비유동성 (2002) — 소형 알파 + amihud_val, amihud_sig, amihud_reason = await calc_amihud(conn, stock) + if amihud_reason: + fin_reasons.append(amihud_reason) + + # 시장 베타 (BAB — Frazzini-Pedersen 2014) + beta_val, beta_sig, beta_reason = await calc_beta(conn, stock) + if beta_reason: + fin_reasons.append(beta_reason) + + # 매직/F-Score → 신호 변환 (점수 기반) + magic_sig = "매수" if magic_score >= 20 else ("관망" if magic_score >= 5 else "관망") + f_sig = "매수" if f_score >= 6 else ("매도" if 0 < f_score <= 2 else "관망") + + # GNN 신호 (graph-engine 예측: 다음날 수익률 %) + graph_pred = graph_pred_map.get(stock, 0.0) + if graph_pred >= 0.3: graph_sig = "매수" + elif graph_pred <= -0.3: graph_sig = "매도" + else: graph_sig = "관망" + graph_score_val = max(-30, min(30, graph_pred * 30)) # ±30 가산점 + + # 앙상블 보팅 (11개 공식: 학술 알파 10 + GNN) + sig_map = { + "magic": magic_sig, + "fscore": f_sig, + "altman": altman_sig, + "peg": peg_sig, + "momentum": mom_sig, + "beneish": beneish_sig, + "gpa": gpa_sig, + "gscore": g_sig, + "amihud": amihud_sig, + "beta": beta_sig, + "graph": graph_sig, + } + ensemble_summary, vote_counts = aggregate_signals(sig_map) + if ensemble_summary: + fin_reasons.append(f"공식보팅 [{ensemble_summary}]") + + # M4: catalyst 가중 뉴스점수로 교체 (위에서 계산한 raw_news 대체) + news_score_w, news_stats = await calc_news_score_weighted(conn, stock, week_ago) + news_score = news_score_w + + # 펀더멘털 통합: 기존 + 추세 + 이익품질 + 매직포뮬러 + F-Score (DCF는 종합점수에 별도 가중) + fundamental_combined = max(-100.0, min(100.0, + fundamental_score + trend_score + eq_score + magic_score + f_score_adj)) + + # 종합 점수 (가중치 재배분) + # 펀더24% + 뉴스18% + 기술15% + 공시10% + 외국인14% + 공매도6% + 가격3% + 안전마진10% + total = (fundamental_combined * 0.24 + news_score * 0.18 + + technical_score * 0.15 + dart_score * 0.10 + + foreign_score * 0.14 + short_score * 0.06 + + price_score * 0.03 + mos_score * 0.10) + # 앙상블 보팅 가산점: 학습 가중치 적용 (max ±18, 균등 시 6공식 합 = 18) + ensemble_bonus = 0.0 + for fname, fsig in sig_map.items(): + w = formula_weights.get(fname, 1.0) + if fsig == "매수": ensemble_bonus += w * 3.0 + elif fsig == "매도": ensemble_bonus -= w * 3.0 + ensemble_bonus = max(-18.0, min(18.0, ensemble_bonus)) + # 미국증시 overnight 보정 (sector_adj + pair_adj, max ±15) + us_info = us_overnight_map.get(stock, {"adj": 0.0, "top": ""}) + us_adj = float(us_info["adj"]) + + # 신규 5개 보조 시그널 (각각 ±10~15) + insider_sig, insider_reason = calc_insider_signal(insider_map.get(stock)) + consensus_sig, consensus_reason = calc_consensus_signal( + consensus_map.get(stock), float(price_change and market_cap and 0) or + (await conn.fetchval( + "SELECT price FROM stock_prices WHERE stock_code=$1 " + "ORDER BY collected_at DESC LIMIT 1", stock) or 0)) + macro_sig, macro_reason = calc_macro_signal(sector, macro_state) + flow_sig, flow_reason = calc_inst_flow_signal(flow_map.get(stock)) + # 밸류 percentile: stock_prices에 누적된 PER 시계열 사용 + per_hist = await conn.fetch( + "SELECT per FROM stock_prices WHERE stock_code=$1 AND per > 0 " + "ORDER BY collected_at DESC LIMIT 200", stock) + per_list = [float(r["per"]) for r in per_hist] + val_sig, val_reason = calc_valuation_percentile(per_list, per) + aux_total = insider_sig + consensus_sig + macro_sig + flow_sig + val_sig + + # H3: 시장 레짐 + 앙상블 + 미국증시 + 5개 보조 + total = max(-100, min(100, + total + regime_adj + ensemble_bonus + us_adj + aux_total)) + rec = get_recommendation(total, vote_counts["매수"], vote_counts["매도"]) + + # 보조 시그널 근거 (점수에 영향 큰 것만) + for sval, rtxt in [(insider_sig, insider_reason), + (consensus_sig, consensus_reason), + (macro_sig, macro_reason), + (flow_sig, flow_reason), + (val_sig, val_reason)]: + if abs(sval) >= 3.0 and rtxt: + fin_reasons.append(f"{rtxt}({sval:+.0f})") + + # M2: 포지션 사이징 + pos_size, vol_60d = await calc_position_size(conn, stock, total) + + # 주요 근거 (뉴스 + 펀더멘털) + news_reasons = await conn.fetch(""" + SELECT reason FROM news_analysis + WHERE primary_stock = $1 AND analyzed_at >= $2 + AND sentiment IN ('호재','악재') AND intensity >= 2 + ORDER BY intensity DESC LIMIT 2 + """, stock, datetime.combine(week_ago, datetime.min.time())) + extra = [] + if foreign_reason: extra.append(foreign_reason) + if short_reason: extra.append(short_reason) + all_reasons = [r["reason"][:80] for r in news_reasons] + fin_reasons[:2] + extra + top_reasons = " | ".join(all_reasons) + + name = await conn.fetchval( + "SELECT corp_name FROM dart_corps WHERE stock_code=$1", stock) + name = name or stock + + await conn.execute(""" + INSERT INTO stock_scores ( + stock_code, stock_name, score_date, + news_positive, news_negative, news_neutral, news_total, + avg_intensity, news_score, + dart_positive, dart_negative, dart_score, + price_change_pct, price_score, + technical_score, foreign_score, short_score, + foreign_ratio, short_weight, + total_score, recommendation, top_reasons, + trend_score, intrinsic_value, margin_of_safety, + earnings_quality, position_size_pct, volatility_60d, + market_regime_adj, sector, + magic_score, f_score, roc_pct, earnings_yield_pct, + altman_z, peg, momentum_pct, beneish_score, + signals, buy_votes, sell_votes, + gpa_pct, g_score, amihud_illiq, market_beta) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19, + $20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33,$34, + $35,$36,$37,$38,$39,$40,$41,$42,$43,$44,$45) + ON CONFLICT (stock_code, score_date) DO UPDATE SET + news_score=$9, dart_score=$12, price_score=$14, + technical_score=$15, foreign_score=$16, short_score=$17, + foreign_ratio=$18, short_weight=$19, + total_score=$20, recommendation=$21, top_reasons=$22, + trend_score=$23, intrinsic_value=$24, margin_of_safety=$25, + earnings_quality=$26, position_size_pct=$27, volatility_60d=$28, + market_regime_adj=$29, sector=$30, + magic_score=$31, f_score=$32, roc_pct=$33, earnings_yield_pct=$34, + altman_z=$35, peg=$36, momentum_pct=$37, beneish_score=$38, + signals=$39, buy_votes=$40, sell_votes=$41, + gpa_pct=$42, g_score=$43, amihud_illiq=$44, market_beta=$45 + """, stock, name, today, + news_stats["pos"], news_stats["neg"], 0, news_stats["total"], + avg_int, news_score, + dart_pos, dart_neg, dart_score, price_change, price_score, + technical_score, foreign_score, short_score, + foreign_ratio, short_weight_val, + total, rec, top_reasons, + trend_score, intrinsic, mos, + eq_score, pos_size, vol_60d, + regime_adj, sector, + magic_score, f_score, roc_pct, ey_pct, + altman_z, peg_val, mom_val, beneish_val, + json.dumps(sig_map, ensure_ascii=False), vote_counts["매수"], vote_counts["매도"], + gpa_val, g_val, amihud_val, beta_val) + + # 미국증시 overnight 보정값 별도 컬럼 저장 + if us_info["adj"] or us_info["top"]: + await conn.execute( + "UPDATE stock_scores SET us_overnight_adj=$1, us_pair_top=$2 " + "WHERE stock_code=$3 AND score_date=$4", + us_adj, us_info["top"], stock, today) + + # 5개 보조 시그널 별도 컬럼 저장 + if any([insider_sig, consensus_sig, macro_sig, flow_sig, val_sig]): + await conn.execute(""" + UPDATE stock_scores SET + insider_signal=$1, consensus_signal=$2, + macro_signal=$3, inst_flow_signal=$4, valuation_pct=$5 + WHERE stock_code=$6 AND score_date=$7 + """, insider_sig, consensus_sig, macro_sig, flow_sig, val_sig, + stock, today) + + # GNN 그래프 점수 별도 컬럼 (graph-engine 예측 기반 ±30 가산) + if stock in graph_pred_map: + await conn.execute( + "UPDATE stock_scores SET graph_score=$1 " + "WHERE stock_code=$2 AND score_date=$3", + graph_score_val, stock, today) + + scored += 1 + + # 섹터 분산 강등: 동일 섹터 매수추천이 SECTOR_MAX_BUY 초과면 '관망'으로 강등. + # sector가 대부분 비면 전 종목이 '기타'로 묶여 전 시장이 4종목으로 붕괴 → + # 섹터 채움률 50% 미만이면 강등 스킵 (sector 백필되면 자동 재활성화). + SECTOR_MAX_BUY = 4 + sec_fill = await conn.fetchval(""" + SELECT COALESCE( + COUNT(*) FILTER (WHERE sector IS NOT NULL AND sector <> '')::float + / NULLIF(COUNT(*), 0), 0) + FROM dart_corps WHERE is_active=true + """) + if sec_fill < 0.5: + logger.warning("scoring.sector_demote_skipped", + sector_fill=round(float(sec_fill), 3)) + else: + await conn.execute(""" + WITH ranked AS ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY COALESCE(NULLIF(sector,''), '기타') + ORDER BY total_score DESC) AS rn + FROM stock_scores + WHERE score_date = $1 + AND recommendation IN ('강력매수', '매수관심') + ) + UPDATE stock_scores + SET recommendation = '관망', + top_reasons = COALESCE(top_reasons, '') || ' | 섹터집중 강등' + FROM ranked + WHERE stock_scores.id = ranked.id AND ranked.rn > $2 + """, today, SECTOR_MAX_BUY) + + # 섹터 분산 후, 매수/매도 추천 일괄 INSERT (stock_recommendations + performance) + rec_rows = await conn.fetch(""" + SELECT stock_code, stock_name, recommendation, total_score, sector, + news_score, dart_score, price_score, technical_score, top_reasons + FROM stock_scores + WHERE score_date = $1 + AND recommendation IN ('강력매수', '매수관심', '매도관심', '강력매도') + """, today) + for r in rec_rows: + await conn.execute(""" + INSERT INTO stock_recommendations ( + stock_code, stock_name, recommendation, total_score, + news_score, dart_score, price_score, technical_score, top_reasons) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) + """, r["stock_code"], r["stock_name"], r["recommendation"], r["total_score"], + r["news_score"], r["dart_score"], r["price_score"], r["technical_score"], r["top_reasons"]) + + entry_price = 0 + if redis_cl: + try: + p_raw = await redis_cl.get(f"price:{r['stock_code']}") + if p_raw: + entry_price = int(json.loads(p_raw).get("price") or 0) + except: pass + if entry_price > 0: + await conn.execute(""" + INSERT INTO recommendation_performance ( + stock_code, stock_name, recommendation, total_score, entry_price, rec_date) + VALUES ($1,$2,$3,$4,$5,CURRENT_DATE) + ON CONFLICT (stock_code, rec_date) DO NOTHING + """, r["stock_code"], r["stock_name"], r["recommendation"], r["total_score"], entry_price) + + if r["recommendation"] == "강력매수": + strong_buy.append((r["stock_name"], r["stock_code"], r["total_score"], + r["technical_score"], 0.0, 0.0, 0.0, 0.0, 0.0)) + elif r["recommendation"] == "강력매도": + strong_sell.append((r["stock_name"], r["stock_code"], r["total_score"], + r["technical_score"], 0.0, 0.0, 0.0)) + + logger.info("scoring.done", scored=scored, recommended=len(rec_rows)) + + # 텔레그램 알림 + if strong_buy or strong_sell: + lines = [f"📊 Trading AI 일간 리포트 ({today})\n"] + if strong_buy: + lines.append("🟢 강력매수 추천 (버핏 가치필터 통과)") + for name, code, score, ts, fs, per, pbr, fg, sw in strong_buy[:5]: + per_str = f"PER {per:.1f}" if per > 0 else "PER -" + pbr_str = f"PBR {pbr:.2f}" if pbr > 0 else "PBR -" + fg_str = f"외국인{fg:+.0f}" if fg != 0 else "" + sw_str = f"공매도{sw:.1f}%" if sw > 0 else "" + extra_str = " | ".join(filter(None, [fg_str, sw_str])) + suffix = f" | {extra_str}" if extra_str else "" + lines.append(f" • {name}({code}) 종합{score:.1f} | 펀더{fs:.0f} | {per_str} {pbr_str}{suffix}") + if strong_sell: + lines.append("\n🔴 강력매도 추천") + for name, code, score, ts, fs, per, pbr in strong_sell[:5]: + lines.append(f" • {name}({code}) 종합{score:.1f} / 기술{ts:.1f}") + await send_telegram("\n".join(lines)) + + return scored + +# ── 정기 브리핑 ─────────────────────────────────────────── + +async def send_briefing(): + """정기 시황 브리핑 — 종목당 1카드(매매가/포지션/재무/근거 통합)""" + now = datetime.now() + ta_redis = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True) + + def _fmt_price(p): + try: return f"{int(p):,}원" if p else "-" + except: return "-" + def _pct(v): + try: return f"{'+' if float(v)>=0 else ''}{float(v):.1f}%" + except: return "-" + def _clean_reasons_tg(raw: str, n: int = 2, ml: int = 50) -> list: + if not raw: return [] + parts = [p.strip() for p in raw.split("|") if p.strip()] + seen, out = set(), [] + for p in parts: + k = p[:25] + if k in seen: continue + seen.add(k) + out.append(p[:ml] + ("…" if len(p) > ml else "")) + if len(out) >= n: break + return out + + try: + async with pg_pool.acquire() as conn: + regime_row = await conn.fetchrow( + "SELECT regime, regime_adj FROM market_regime ORDER BY dt DESC LIMIT 1") + buy_rows = await conn.fetch(""" + SELECT s.stock_name, s.stock_code, s.total_score, s.technical_score, + s.recommendation, s.trend_score, s.margin_of_safety, + s.position_size_pct, s.sector, s.top_reasons, + f.roe, f.debt_ratio + FROM stock_scores s + LEFT JOIN dart_financials f + ON f.stock_code=s.stock_code AND f.reprt_code='11011' + AND f.bsns_year = (SELECT MAX(bsns_year) FROM dart_financials f2 + WHERE f2.stock_code=s.stock_code AND f2.reprt_code='11011') + WHERE s.score_date = (SELECT MAX(score_date) FROM stock_scores) + AND s.recommendation IN ('강력매수', '매수관심') + ORDER BY s.total_score DESC LIMIT 5 + """) + sell_rows = await conn.fetch(""" + SELECT stock_name, stock_code, total_score, recommendation, top_reasons + FROM stock_scores + WHERE score_date = (SELECT MAX(score_date) FROM stock_scores) + AND recommendation IN ('강력매도', '매도관심') + ORDER BY total_score ASC LIMIT 3 + """) + + async def _get_price_ta(code: str): + price, chg, tg = None, 0.0, {} + if redis_cl: + try: + p = await redis_cl.get(f"price:{code}") + if p: + pd = json.loads(p) + price = int(pd.get("price") or 0) or None + chg = float(pd.get("change_pct", 0) or 0) + except: pass + try: + ta_raw = await ta_redis.get(f"ta:{code}") + if ta_raw: + ta = json.loads(ta_raw) + tg = ta.get("targets", {}) or {} + except: pass + return price, chg, tg + + head = f"📊 Trading AI 브리핑 ({now.strftime('%m/%d %H:%M')})" + regime_str = (f"\n시장: {regime_row['regime']} " + f"({_pct(regime_row['regime_adj'])})") if regime_row else "" + lines = [head + regime_str, ""] + + if buy_rows: + lines.append("🟢 매수 추천") + for i, row in enumerate(buy_rows, 1): + code = row["stock_code"] + icon = "🔥" if row["recommendation"] == "강력매수" else "✅" + price, chg, tg = await _get_price_ta(code) + sector = row["sector"] or "기타" + roe = row["roe"]; debt = row["debt_ratio"] + fin_str = "" + if roe is not None and debt is not None: + fin_str = f"ROE {roe:.1f}% · 부채 {debt:.0f}%" + lines.append( + f"\n{i}. {icon} {row['stock_name']} " + f"{code} · {sector}") + price_line = f"{_fmt_price(price)} ({_pct(chg)})" if price else "가격 갱신 대기" + lines.append(f"💰 {price_line} · 점수 {row['total_score']:.1f}") + if tg.get("t1"): + pos = row["position_size_pct"] or 0 + pos_str = f" · 비중 {pos:.1f}%" if pos else "" + lines.append( + f"🎯 진입 {_fmt_price(tg.get('entry_price'))} → " + f"T1 {_fmt_price(tg.get('t1'))} ({_pct(tg.get('t1_pct'))}) " + f"50%매도{pos_str}") + lines.append( + f" T2 {_fmt_price(tg.get('t2'))} 30% / " + f"T3 {_fmt_price(tg.get('t3'))} 20%") + if tg.get("trailing_stop"): + lines.append( + f"🛑 손절 {_fmt_price(tg.get('stop_loss'))} · " + f"Trailing {_fmt_price(tg.get('trailing_stop'))}") + if fin_str: + extras = [] + mos = row["margin_of_safety"] + if mos and mos > 25: + extras.append(f"안전마진 {mos:.0f}%") + if extras: + lines.append(f"📈 {fin_str} · {' · '.join(extras)}") + else: + lines.append(f"📈 {fin_str}") + for r in _clean_reasons_tg(row["top_reasons"]): + lines.append(f" → {r}") + + if sell_rows: + lines.append("\n🔴 회피") + for row in sell_rows: + code = row["stock_code"] + icon = "⛔" if row["recommendation"] == "강력매도" else "⚠️" + price, chg, _ = await _get_price_ta(code) + price_str = f"{_fmt_price(price)} ({_pct(chg)})" if price else "-" + lines.append(f" {icon} {row['stock_name']} " + f"{code} · {price_str} · 점수 {row['total_score']:.1f}") + for r in _clean_reasons_tg(row["top_reasons"], n=1): + lines.append(f" → {r}") + + if not buy_rows and not sell_rows: + lines.append("\n분석 중입니다. 장 마감 후 갱신.") + + await send_telegram("\n".join(lines)) + logger.info("briefing.sent", time=now.strftime("%H:%M")) + except Exception as e: + logger.error("briefing.err", error=str(e)) + finally: + await ta_redis.aclose() + +# ── FastAPI ──────────────────────────────────────────────── + +app = FastAPI(title="종목 점수 엔진") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.on_event("startup") +async def startup(): + global pg_pool, redis_cl + pg_pool = await asyncpg.create_pool( + host=PG_HOST, port=PG_PORT, database=PG_DB, + user=PG_USER, password=PG_PASS, min_size=2, max_size=5) + redis_cl = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=3, decode_responses=True) + await init_db() + scheduler.add_job(calculate_daily_scores, "cron", + day_of_week="mon-fri", hour=16, minute=30, + id="daily_score", replace_existing=True) + for hr, mn in [(8, 0), (12, 0), (16, 0), (18, 0)]: + scheduler.add_job(send_briefing, "cron", + day_of_week="mon-fri", hour=hr, minute=mn, + id=f"briefing_{hr}_{mn}", replace_existing=True) + # 데이터 정리: 매일 새벽 4시 + scheduler.add_job(cleanup_old_data, "cron", + hour=4, minute=0, + id="cleanup", replace_existing=True) + # 성과 추적: 매일 18시 가격 업데이트 + scheduler.add_job(update_performance_prices, "cron", + day_of_week="mon-fri", hour=18, minute=0, + id="perf_update", replace_existing=True) + # 헬스체크: 10분마다 + scheduler.add_job(health_check_services, "interval", + minutes=10, id="health_check", replace_existing=True) + + # 자동 학습: 매주 일요일 + # 04:00 — 공식 가중치 학습 (90일 백테스트) + # 05:00 — 예상가 모델 학습 (선형회귀 + RF + XGBoost) + # 두 함수 모두 표본 부족 시 graceful (return early) — 데이터 누적되면 자동 활성화 + scheduler.add_job(lambda: learn_weights(days=90), "cron", + day_of_week="sun", hour=4, minute=0, + id="learn_weights", replace_existing=True) + scheduler.add_job(lambda: learn_pricing(days=90), "cron", + day_of_week="sun", hour=5, minute=0, + id="learn_pricing", replace_existing=True) + # AI 심층분석: 평일 17:00 (16:30 스코어링 직후 당일 추천종목 대상) + scheduler.add_job(deep_analysis_batch_job, "cron", + day_of_week="mon-fri", hour=17, minute=0, + id="deep_batch", replace_existing=True) + scheduler.start() + + # 평일 17:00 deep_batch가 컨테이너 재배포/다운으로 17:00에 떠있지 않으면 + # APScheduler MemoryJobStore는 놓친 실행을 catch-up하지 않음 → startup 시 1회 보정. + async def _deep_batch_catchup(): + now = datetime.now() # 컨테이너 TZ=Asia/Seoul + if now.weekday() >= 5 or now.hour < 17: # 주말 or 17:00 이전이면 정시 발화에 맡김 + return + async with pg_pool.acquire() as conn: + done = await conn.fetchval( + "SELECT 1 FROM deep_analysis WHERE analysis_date=CURRENT_DATE LIMIT 1") + if done: # 오늘치 이미 있으면 스킵 + return + logger.info("deep_batch.catchup_start") + await deep_analysis_batch_job() + asyncio.create_task(_deep_batch_catchup()) + + logger.info("score-engine.started") + +@app.on_event("shutdown") +async def shutdown(): + scheduler.shutdown() + if pg_pool: await pg_pool.close() + +async def cleanup_old_data(): + """오래된 데이터 정리 - DB 비대화 방지""" + async with pg_pool.acquire() as conn: + # stock_prices: 30일 이상 된 것 삭제 + deleted_prices = await conn.fetchval( + "WITH d AS (DELETE FROM stock_prices WHERE collected_at < NOW() - INTERVAL '30 days' RETURNING 1) SELECT COUNT(*) FROM d") + # stock_recommendations: 60일 이상 된 것 삭제 + deleted_recs = await conn.fetchval( + "WITH d AS (DELETE FROM stock_recommendations WHERE recommended_at < NOW() - INTERVAL '60 days' RETURNING 1) SELECT COUNT(*) FROM d") + # news_analysis: 90일 이상 된 것 삭제 (오래된 뉴스는 분석 불필요) + deleted_news = await conn.fetchval( + "WITH d AS (DELETE FROM news_analysis WHERE analyzed_at < NOW() - INTERVAL '90 days' RETURNING 1) SELECT COUNT(*) FROM d") + # trade_signals: 30일 이상 된 것 삭제 + deleted_signals = await conn.fetchval( + "WITH d AS (DELETE FROM trade_signals WHERE created_at < NOW() - INTERVAL '30 days' RETURNING 1) SELECT COUNT(*) FROM d") + logger.info("cleanup.done", + prices=deleted_prices, recs=deleted_recs, + news=deleted_news, signals=deleted_signals) + +@app.get("/health") +async def health(): + return {"status": "ok"} + +@app.post("/score/calculate") +async def manual_calc(): + n = await calculate_daily_scores() + return {"status": "done", "scored": n} + + +@app.post("/ohlcv/backfill") +async def ohlcv_backfill(count: int = Query(default=0, ge=0), + days: int = Query(default=400, ge=30, le=1200)): + """활성종목 OHLCV 네이버 백필 (momentum/BAB/기술 복구). count=0=전체. + 대량·장시간이라 백그라운드 실행 → 즉시 반환.""" + async with pg_pool.acquire() as conn: + q = ("SELECT stock_code FROM dart_corps WHERE is_active=true " + "ORDER BY stock_code") + ("" if count <= 0 else f" LIMIT {count}") + codes = [r["stock_code"] for r in await conn.fetch(q)] + + sem = asyncio.Semaphore(5) # pg_pool max_size·네이버 예의 고려 + tot = {"codes": len(codes), "ok": 0, "bars": 0} + + async def one(cd: str): + async with sem: + async with pg_pool.acquire() as conn: + n = await fetch_naver_ohlcv(conn, cd, days) + if n: + tot["ok"] += 1 + tot["bars"] += n + await asyncio.sleep(0.05) + + async def run(): + await asyncio.gather(*[one(c) for c in codes]) + logger.info("ohlcv_backfill.done", **tot) + + asyncio.create_task(run()) + return {"status": "started", "codes": len(codes), "days": days} + +@app.post("/briefing/send") +async def manual_briefing(): + await send_briefing() + return {"status": "sent"} + +@app.get("/ranking") +async def ranking(date: str = Query(default=""), limit: int = Query(default=30)): + d = date or str(datetime.now().date()) + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT stock_code, stock_name, total_score, news_score, dart_score, + price_score, technical_score, recommendation, top_reasons, + news_total, news_positive, news_negative + FROM stock_scores WHERE score_date=$1 + ORDER BY total_score DESC LIMIT $2 + """, datetime.strptime(d, "%Y-%m-%d").date(), limit) + return [dict(r) for r in rows] + +@app.get("/recommendations") +async def recommendations(days: int = Query(default=7)): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT stock_code, stock_name, recommendation, total_score, + news_score, dart_score, price_score, technical_score, + top_reasons, recommended_at + FROM stock_recommendations + WHERE recommended_at >= NOW() - INTERVAL '%s days' + ORDER BY total_score DESC LIMIT 30 + """ % days) + return [dict(r) for r in rows] + +@app.get("/performance/summary") +async def performance_summary(): + """추천 성과 요약 (7일/30일 수익률, 승률)""" + async with pg_pool.acquire() as conn: + stats = await conn.fetchrow(""" + SELECT + COUNT(*) AS total, + COUNT(*) FILTER (WHERE return_7d IS NOT NULL) AS measured_7d, + ROUND(AVG(return_7d) FILTER (WHERE return_7d IS NOT NULL)::numeric, 2) AS avg_return_7d, + COUNT(*) FILTER (WHERE return_7d > 0) AS wins_7d, + ROUND(AVG(return_30d) FILTER (WHERE return_30d IS NOT NULL)::numeric, 2) AS avg_return_30d, + COUNT(*) FILTER (WHERE return_30d > 0) AS wins_30d, + COUNT(*) FILTER (WHERE return_30d IS NOT NULL) AS measured_30d, + ROUND(AVG(return_7d) FILTER (WHERE recommendation='강력매수' AND return_7d IS NOT NULL)::numeric, 2) AS strong_buy_avg_7d + FROM recommendation_performance + WHERE rec_date >= CURRENT_DATE - 90 + """) + recent = await conn.fetch(""" + SELECT stock_code, stock_name, recommendation, entry_price, + price_7d, return_7d, price_30d, return_30d, rec_date + FROM recommendation_performance + WHERE return_7d IS NOT NULL OR return_30d IS NOT NULL + ORDER BY rec_date DESC LIMIT 30 + """) + def s(r): return {**dict(r), "rec_date": str(r["rec_date"])} + return {"summary": dict(stats) if stats else {}, "recent": [s(r) for r in recent]} + +@app.get("/stock/{code}") +async def stock_detail(code: str): + async with pg_pool.acquire() as conn: + scores = await conn.fetch( + "SELECT * FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 30", code) + news = await conn.fetch(""" + SELECT title, sentiment, intensity, reason, investment_action, source, analyzed_at + FROM news_analysis WHERE primary_stock=$1 OR stock_codes::text LIKE $2 + ORDER BY analyzed_at DESC LIMIT 20 + """, code, f'%{code}%') + fin = await conn.fetch( + "SELECT * FROM dart_financials WHERE stock_code=$1 ORDER BY bsns_year DESC", code) + price = None + ta = None + if redis_cl: + try: + c = await redis_cl.get(f"price:{code}") + if c: price = json.loads(c) + t = await redis_cl.get(f"ta:{code}") + if t: ta = json.loads(t) + except: pass + return { + "code": code, + "scores": [dict(r) for r in scores], + "news": [dict(r) for r in news], + "financials": [dict(r) for r in fin], + "price": price, + "technical": ta, + } + + +@app.get("/backtest") +async def backtest(days: int = Query(default=180, ge=30, le=365)): + """ + M1: 과거 추천 종목의 7d/30d 수익률, KOSPI 대비 알파, 적중률, 샤프, MDD 산출 + """ + since = date.today() - timedelta(days=days) + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT recommendation, total_score, return_7d, return_30d, + alpha_7d, alpha_30d, kospi_return_7d, kospi_return_30d, rec_date + FROM recommendation_performance + WHERE rec_date >= $1 + """, since) + + if not rows: + return {"period_days": days, "n": 0, "msg": "데이터 없음"} + + def _summary(returns: list, alphas: list) -> dict: + if not returns: + return {"n": 0} + n = len(returns) + avg_ret = sum(returns) / n + sd = (sum((r - avg_ret) ** 2 for r in returns) / n) ** 0.5 if n > 1 else 0 + win = sum(1 for r in returns if r > 0) / n * 100 + # 일간 변동성 가정 안 하고 단순 샤프 근사 (mean/sd, RFR=0) + sharpe = avg_ret / sd if sd > 0 else 0 + mdd = min(returns) + avg_alpha = sum(alphas) / len(alphas) if alphas else None + return { + "n": n, + "avg_return_pct": round(avg_ret, 2), + "win_rate_pct": round(win, 1), + "stdev": round(sd, 2), + "sharpe": round(sharpe, 2), + "max_drawdown_pct": round(mdd, 2), + "avg_alpha_pct": round(avg_alpha, 2) if avg_alpha is not None else None, + } + + overall = {} + for window in ("7d", "30d"): + rs = [float(r[f"return_{window}"]) for r in rows if r[f"return_{window}"] is not None] + als = [float(r[f"alpha_{window}"]) for r in rows if r[f"alpha_{window}"] is not None] + overall[window] = _summary(rs, als) + + by_rec = {} + for rec in ("강력매수", "매수관심"): + rs7 = [float(r["return_7d"]) for r in rows + if r["recommendation"] == rec and r["return_7d"] is not None] + als7 = [float(r["alpha_7d"]) for r in rows + if r["recommendation"] == rec and r["alpha_7d"] is not None] + by_rec[rec] = _summary(rs7, als7) + + return { + "period_days": days, + "total_recommendations": len(rows), + "overall": overall, + "by_recommendation_7d": by_rec, + } + + +@app.post("/learn-weights") +async def learn_weights(days: int = Query(default=90, ge=14, le=365)): + """ + 백테스트 기반 공식별 가중치 학습. + 각 공식이 '매수' 신호를 낸 종목들의 평균 7일 수익률 - '매도' 신호 종목 평균 = edge + edge가 큰 공식일수록 가중치 ↑ → ensemble 보팅에 반영 + """ + since = date.today() - timedelta(days=days) + formulas = ["magic", "fscore", "altman", "peg", "momentum", "beneish"] + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT s.signals, p.return_7d, p.return_30d + FROM stock_scores s + JOIN recommendation_performance p + ON s.stock_code = p.stock_code AND s.score_date = p.rec_date + WHERE p.rec_date >= $1 AND p.return_7d IS NOT NULL + """, since) + + if not rows: + return {"period_days": days, "sample": 0, + "msg": "백테스트 표본 부족 — 추천·성과 데이터 누적 후 재학습", + "weights": {f: 1.0 for f in formulas}} + + out = {} + for f in formulas: + buy_rets, sell_rets = [], [] + for r in rows: + sigs = r["signals"] or {} + if isinstance(sigs, str): + try: sigs = json.loads(sigs) + except: sigs = {} + sig = sigs.get(f, "관망") + if sig == "매수": + buy_rets.append(float(r["return_7d"])) + elif sig == "매도": + sell_rets.append(float(r["return_7d"])) + avg_buy = sum(buy_rets)/len(buy_rets) if buy_rets else 0.0 + avg_sell = sum(sell_rets)/len(sell_rets) if sell_rets else 0.0 + out[f] = { + "buy_n": len(buy_rets), "buy_avg_return_7d": round(avg_buy, 2), + "sell_n": len(sell_rets), "sell_avg_return_7d": round(avg_sell, 2), + "edge": round(avg_buy - avg_sell, 2), + } + + # edge 양수만 가중치 부여, 합 6 (균등) 으로 정규화 + edges = {f: max(0.0, out[f]["edge"]) for f in formulas} + total_edge = sum(edges.values()) + if total_edge > 0: + weights = {f: round(edges[f] / total_edge * len(formulas), 3) for f in formulas} + else: + weights = {f: 1.0 for f in formulas} + + await conn.execute(""" + INSERT INTO weight_config (config_date, weights, period_days, sample_size) + VALUES (CURRENT_DATE, $1, $2, $3) + ON CONFLICT (config_date) DO UPDATE + SET weights=$1, period_days=$2, sample_size=$3 + """, json.dumps(weights), days, len(rows)) + + return {"period_days": days, "sample": len(rows), + "by_formula": out, "weights": weights, + "applied": "다음 /score/calculate 부터 자동 적용"} + + +@app.get("/learn-weights") +async def get_weights(): + """현재 적용 중인 공식별 학습 가중치""" + async with pg_pool.acquire() as conn: + cfg = await conn.fetchrow(""" + SELECT config_date, weights, period_days, sample_size, created_at + FROM weight_config ORDER BY config_date DESC LIMIT 1 + """) + if not cfg: + return {"status": "default", + "weights": {f: 1.0 for f in ["magic","fscore","altman","peg","momentum","beneish"]}} + w = cfg["weights"] + if isinstance(w, str): + try: w = json.loads(w) + except: w = {} + return {"status": "learned", "config_date": str(cfg["config_date"]), + "period_days": cfg["period_days"], "sample_size": cfg["sample_size"], + "weights": w} + + +ECOS_API_KEY = os.getenv("ECOS_API_KEY", "") +ECOS_INDICATORS = { + # 한국은행 ECOS 통계표 코드 (실제 코드 키 발급 후 ecos.bok.or.kr에서 조회) + "GDP_growth": ("200Y001", "10101"), # 실질GDP 전년동기비 + "CPI": ("901Y009", "0"), # 소비자물가지수 + "Unemployment":("901Y027", "I61BC"), # 실업률 + "Base_rate": ("722Y001", "0101000"), # 한국은행 기준금리 + "ConsumerSentiment": ("511Y002", "FME"), # 소비자심리지수 +} + +@app.get("/api/risk/{code}") +async def risk_var(code: str, days: int = Query(default=60), confidence: float = Query(default=0.95)): + """ + Historical VaR 95%: 60일 일별 수익률 분포의 5% 분위수 + 종목별 1일 / 5일 / 30일 VaR 계산 (단순 sqrt(t) 스케일) + """ + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT close_price FROM stock_ohlcv + WHERE stock_code=$1 ORDER BY dt DESC LIMIT $2 + """, code, days + 1) + if len(rows) < 30: + return {"code": code, "msg": f"데이터 부족 ({len(rows)}일)"} + closes = [float(r["close_price"]) for r in rows if r["close_price"] > 0] + rets = [(closes[i] - closes[i+1]) / closes[i+1] for i in range(len(closes)-1) if closes[i+1] > 0] + if not rets: return {"code": code, "msg": "수익률 계산 실패"} + rets_sorted = sorted(rets) + pct_5 = rets_sorted[int(len(rets_sorted) * (1 - confidence))] + pct_1 = rets_sorted[int(len(rets_sorted) * 0.01)] + avg = sum(rets) / len(rets) + var_d = (sum((r - avg) ** 2 for r in rets) / len(rets)) ** 0.5 + return { + "code": code, "n_days": len(rets), + "var_95_1d_pct": round(pct_5 * 100, 2), + "var_99_1d_pct": round(pct_1 * 100, 2), + "var_95_5d_pct": round(pct_5 * (5 ** 0.5) * 100, 2), + "var_95_30d_pct": round(pct_5 * (30 ** 0.5) * 100, 2), + "daily_volatility_pct": round(var_d * 100, 2), + "annual_volatility_pct": round(var_d * (252 ** 0.5) * 100, 2), + "interpretation": + f"95% 신뢰수준에서 1일 최대 {abs(pct_5*100):.2f}% 손실, " + f"30일 최대 {abs(pct_5*(30**0.5)*100):.1f}% 손실 가능" + } + + +@app.get("/api/garch-vol/{code}") +async def garch_vol(code: str, horizon: int = Query(default=5)): + """GARCH(1,1) 변동성 예측 — 다음 N일 평균 변동성""" + try: + from arch import arch_model + import numpy as np + except Exception as e: + return {"code": code, "err": f"arch 라이브러리 미설치: {e}"} + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT close_price FROM stock_ohlcv + WHERE stock_code=$1 ORDER BY dt DESC LIMIT 252 + """, code) + if len(rows) < 100: + return {"code": code, "msg": f"데이터 부족 ({len(rows)}일, 최소 100)"} + closes = np.array([float(r["close_price"]) for r in rows[::-1] if r["close_price"] > 0]) + rets = np.diff(closes) / closes[:-1] * 100 # 백분율 수익률 + try: + am = arch_model(rets, mean='Zero', vol='GARCH', p=1, q=1, dist='normal') + res = am.fit(disp='off') + forecast = res.forecast(horizon=horizon) + vols = forecast.variance.iloc[-1].values ** 0.5 + cur_vol = float(vols[0]) + avg_vol = float(vols.mean()) + # 역사적 변동성 비교 + hist_vol = float(rets.std()) + return { + "code": code, "n_days": len(rets), + "garch_next_day_vol_pct": round(cur_vol, 3), + f"garch_avg_{horizon}d_vol_pct": round(avg_vol, 3), + "hist_vol_pct": round(hist_vol, 3), + "vol_ratio_garch_vs_hist": round(cur_vol / hist_vol, 2) if hist_vol else 0, + "interpretation": ( + "변동성 확장 국면 (GARCH > 역사 평균)" if cur_vol > hist_vol * 1.1 + else "변동성 축소 국면" if cur_vol < hist_vol * 0.9 + else "안정 국면" + ), + } + except Exception as e: + return {"code": code, "err": f"GARCH 적합 실패: {e}"} + + +@app.get("/api/macro-kr") +async def macro_kr(): + """한국은행 ECOS 매크로 지표 (ECOS_API_KEY 환경변수 필요) + 키 발급: https://ecos.bok.or.kr/api 무료 가입 후 신청 + """ + if not ECOS_API_KEY: + return {"status": "no_key", + "msg": "ECOS_API_KEY 미설정. https://ecos.bok.or.kr/api 에서 키 발급 후 .env에 등록", + "indicators": list(ECOS_INDICATORS.keys())} + out = {} + end = datetime.now().strftime("%Y%m") + start = (datetime.now() - timedelta(days=60)).strftime("%Y%m") + async with httpx.AsyncClient(timeout=10) as c: + for name, (stat_code, item_code) in ECOS_INDICATORS.items(): + try: + url = (f"https://ecos.bok.or.kr/api/StatisticSearch/{ECOS_API_KEY}/json/kr/1/3/" + f"{stat_code}/M/{start}/{end}/{item_code}") + r = await c.get(url) + if r.status_code != 200: continue + d = r.json() + rows = d.get("StatisticSearch", {}).get("row", []) + if rows: + latest = rows[-1] + out[name] = {"value": latest.get("DATA_VALUE"), + "time": latest.get("TIME"), + "unit": latest.get("UNIT_NAME")} + except Exception as e: + out[name] = {"err": str(e)} + return {"status": "ok", "data": out, "ts": datetime.now().isoformat()} + + +@app.post("/learn-pricing") +async def learn_pricing(days: int = Query(default=90, ge=14, le=365)): + """ + D + E: 백테스트 데이터로 두 모델 학습 + - D: 단순 선형회귀 (점수 → 30일 수익률 계수) + - E: Random Forest (다변수 입력 → 30일 수익률) + 표본 부족 시 graceful (default 모델 또는 None) + """ + since = date.today() - timedelta(days=days) + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT s.total_score, s.magic_score, s.f_score, s.altman_z, + s.peg, s.momentum_pct, s.beneish_score, + p.return_30d, p.return_7d + FROM stock_scores s + JOIN recommendation_performance p + ON s.stock_code=p.stock_code AND s.score_date=p.rec_date + WHERE p.rec_date >= $1 + """, since) + + out = {"period_days": days, "sample": len(rows)} + if len(rows) < 10: + out["msg"] = f"표본 {len(rows)} 부족 (최소 10) — 추천·성과 누적 후 재학습" + out["linear_coef"] = None + out["rf_feature_importance"] = None + return out + + try: + import numpy as np + from sklearn.linear_model import LinearRegression + from sklearn.ensemble import RandomForestRegressor + from sklearn.metrics import r2_score + except Exception as e: + return {**out, "err": f"sklearn import 실패: {e}"} + + # D. 단순 선형회귀: total_score → return_30d (Walk-forward 적용) + valid_30d = [r for r in rows if r["return_30d"] is not None] + linear_summary = None + if len(valid_30d) >= 10: + X = np.array([[float(r["total_score"])] for r in valid_30d]) + y = np.array([float(r["return_30d"]) for r in valid_30d]) + m = LinearRegression().fit(X, y) + pred = m.predict(X) + # Walk-forward: 70/30 시간순 split (look-ahead bias 회피) + split = int(len(valid_30d) * 0.7) + oos_r2 = None + if split >= 5 and len(valid_30d) - split >= 3: + m_train = LinearRegression().fit(X[:split], y[:split]) + y_test_pred = m_train.predict(X[split:]) + oos_r2 = round(r2_score(y[split:], y_test_pred), 3) + linear_summary = { + "coef": round(float(m.coef_[0]), 4), + "intercept": round(float(m.intercept_), 4), + "r2_in_sample": round(r2_score(y, pred), 3), + "r2_out_of_sample_walkforward": oos_r2, + "n": len(valid_30d), + "interpretation": + f"점수 1점 상승 ≈ 30일 수익률 {m.coef_[0]:+.3f}%p, " + f"in-sample R²={r2_score(y, pred):.2f}, " + f"OOS R²={oos_r2 if oos_r2 is not None else 'N/A'} (look-ahead bias 회피)" + } + + # E. Random Forest + XGBoost: 다변수 → return_30d + rf_summary = None + if len(valid_30d) >= 20: + feature_names = ["total_score", "magic_score", "f_score", "altman_z", + "peg", "momentum_pct", "beneish_score"] + X = np.array([[float(r[fn] or 0) for fn in feature_names] for r in valid_30d]) + y = np.array([float(r["return_30d"]) for r in valid_30d]) + rf = RandomForestRegressor(n_estimators=80, max_depth=5, random_state=42).fit(X, y) + importance = dict(zip(feature_names, [round(float(v), 3) for v in rf.feature_importances_])) + pred = rf.predict(X) + rf_summary = { + "n": len(valid_30d), + "r2_train": round(r2_score(y, pred), 3), + "feature_importance": dict(sorted(importance.items(), key=lambda x: -x[1])), + } + # XGBoost (gradient boosting) — RF보다 일반적으로 우월 + try: + import xgboost as xgb + xgb_model = xgb.XGBRegressor(n_estimators=100, max_depth=4, + learning_rate=0.05, random_state=42, + objective='reg:squarederror').fit(X, y) + xgb_pred = xgb_model.predict(X) + xgb_imp = dict(zip(feature_names, [round(float(v), 3) + for v in xgb_model.feature_importances_])) + rf_summary["xgb_r2"] = round(r2_score(y, xgb_pred), 3) + rf_summary["xgb_feature_importance"] = dict(sorted(xgb_imp.items(), key=lambda x: -x[1])) + except Exception as ex: + rf_summary["xgb_err"] = str(ex) + # 학습된 모델 직렬화 → DB 저장 (간단 버전: feature_importance만 JSONB로) + async with pg_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS pricing_model ( + model_date DATE PRIMARY KEY, + linear_coef FLOAT, linear_intercept FLOAT, linear_r2 FLOAT, + rf_features JSONB, rf_r2 FLOAT, sample_size INTEGER, + period_days INTEGER, created_at TIMESTAMP DEFAULT NOW() + ) + """) + await conn.execute(""" + INSERT INTO pricing_model (model_date, linear_coef, linear_intercept, + linear_r2, rf_features, rf_r2, sample_size, period_days) + VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (model_date) DO UPDATE SET + linear_coef=$1, linear_intercept=$2, linear_r2=$3, + rf_features=$4, rf_r2=$5, sample_size=$6, period_days=$7 + """, linear_summary["coef"] if linear_summary else None, + linear_summary["intercept"] if linear_summary else None, + linear_summary["r2_in_sample"] if linear_summary else None, + json.dumps(importance), rf_summary["r2_train"], + len(valid_30d), days) + + return {**out, "linear": linear_summary, "rf": rf_summary, + "applied": "다음 /predict-price 호출부터 적용"} + + +@app.get("/predict-price/{code}") +async def predict_price(code: str): + """학습된 모델로 N일 후 예상 수익률·가격 추정""" + async with pg_pool.acquire() as conn: + s = await conn.fetchrow(""" + SELECT total_score, magic_score, f_score, altman_z, + peg, momentum_pct, beneish_score, sector + FROM stock_scores WHERE stock_code=$1 + ORDER BY score_date DESC LIMIT 1 + """, code) + m = await conn.fetchrow(""" + SELECT linear_coef, linear_intercept, linear_r2, rf_r2, sample_size + FROM pricing_model ORDER BY model_date DESC LIMIT 1 + """) + # 현재가 — Redis or stock_prices + cur_price = 0 + if redis_cl: + try: + p = await redis_cl.get(f"price:{code}") + if p: + cur_price = int(json.loads(p).get("price") or 0) + except: pass + if not cur_price: + pr = await conn.fetchrow( + "SELECT price FROM stock_prices WHERE stock_code=$1 ORDER BY collected_at DESC LIMIT 1", + code) + if pr: cur_price = int(pr["price"] or 0) + + if not s: + return {"code": code, "msg": "stock_scores 데이터 없음"} + if not m: + return {"code": code, "msg": "학습 모델 없음 — 먼저 /learn-pricing 호출"} + + pred_30d_pct = (m["linear_intercept"] or 0) + (m["linear_coef"] or 0) * float(s["total_score"]) + pred_price = int(cur_price * (1 + pred_30d_pct / 100)) if cur_price else None + + return { + "code": code, + "current_price": cur_price, + "current_score": float(s["total_score"]), + "predicted_30d_return_pct": round(pred_30d_pct, 2), + "predicted_30d_price": pred_price, + "model": {"r2": m["linear_r2"], "n": m["sample_size"]}, + "disclaimer": "선형 회귀 기반 단순 추정. 신뢰도 R² 참고. RF 모델은 feature 기반 더 정교 (내부)", + } + + +@app.get("/sector/concentration") +async def sector_concentration(): + """H4: 현재 강력매수/매수관심 종목의 섹터 분포 (집중도 경고)""" + today = date.today() + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT sector, COUNT(*) AS n, AVG(total_score)::float AS avg_score + FROM stock_scores + WHERE score_date=$1 AND recommendation IN ('강력매수','매수관심') + GROUP BY sector ORDER BY n DESC + """, today) + total = sum(r["n"] for r in rows) or 1 + out = [] + warnings = [] + for r in rows: + pct = r["n"] / total * 100 + out.append({ + "sector": r["sector"] or "(미분류)", + "count": r["n"], + "share_pct": round(pct, 1), + "avg_score": round(r["avg_score"], 1), + }) + if pct >= 30 and r["sector"]: + warnings.append(f"{r['sector']} 섹터 집중 {pct:.0f}% (>30%)") + return {"total": total, "sectors": out, "warnings": warnings} + + +# ── RAG + EXAONE 종목 심층분석 ───────────────────────────── +# 정량 점수·재무추세·기술적·뉴스흐름·앙상블 보팅을 RAG 컨텍스트로 모아 +# EXAONE에 버핏 관점 매수/매도 판단을 받는다. (catalyst enum 미신뢰 → +# 뉴스는 sentiment/intensity/reason 원문으로만 컨텍스트 구성) + +_DEEP_RECS = {"강력매수", "매수", "중립", "매도", "강력매도"} + + +def _jload(v): + if isinstance(v, (dict, list)): + return v + try: + return json.loads(v) if v else {} + except Exception: + return {} + + +async def _build_rag_context(conn, code: str) -> tuple[str, dict]: + """종목 RAG 컨텍스트 문자열 + 메타(name/quant_score/quant_rec/targets) 반환""" + meta = {"name": code, "quant_score": 0.0, "quant_rec": "-", "targets": {}} + ctx: list[str] = [] + + corp = await conn.fetchrow( + "SELECT corp_name FROM dart_corps WHERE stock_code=$1", code) + sec = await get_stock_sector(conn, code) + sec_str = sec if sec and sec != "기타" else "미분류" + + sc = await conn.fetchrow( + "SELECT * FROM stock_scores WHERE stock_code=$1 ORDER BY score_date DESC LIMIT 1", code) + if sc: + meta["name"] = sc["stock_name"] or (corp and corp["corp_name"]) or code + meta["quant_score"] = float(sc["total_score"] or 0) + meta["quant_rec"] = sc["recommendation"] or "-" + elif corp: + meta["name"] = corp["corp_name"] + + ctx.append(f"· 종목: {meta['name']}({code}) / 섹터: {sec_str}") + + if sc: + ctx.append( + f"· 퀀트 종합점수 {meta['quant_score']:.1f} → 시스템판정 [{meta['quant_rec']}] " + f"(매수보팅 {sc['buy_votes']} / 매도보팅 {sc['sell_votes']})") + ctx.append( + f"· 세부점수: 뉴스 {sc['news_score']:.0f} 공시 {sc['dart_score']:.0f} " + f"기술 {sc['technical_score']:.0f} 외국인 {sc['foreign_score']:.0f} " + f"공매도 {sc['short_score']:.0f} 추세 {sc['trend_score']:.0f} " + f"이익품질 {sc['earnings_quality']:.0f}") + ctx.append( + f"· 학술공식: 매직 {sc['magic_score']:.0f} F-Score {sc['f_score']} " + f"알트만Z {sc['altman_z']:.2f} PEG {sc['peg']:.2f} " + f"모멘텀 {sc['momentum_pct']:.1f}% Beneish {sc['beneish_score']:.0f} " + f"GP/A {sc['gpa_pct']:.1f}% G-Score {sc['g_score']} 베타 {sc['market_beta']:.2f}") + iv = int(sc["intrinsic_value"] or 0) + if iv > 0: + ctx.append(f"· DCF 내재가치 {iv:,}원 / 안전마진 {sc['margin_of_safety']:.0f}%") + sig = _jload(sc["signals"]) + if sig: + sig_str = " ".join(f"{k}:{v}" for k, v in sig.items()) + ctx.append(f"· 공식별 신호: {sig_str}") + + fins = await conn.fetch(""" + SELECT bsns_year, revenue, operating_profit, net_income, + roe, operating_margin, debt_ratio, fcf_ratio, revenue_growth + FROM dart_financials + WHERE stock_code=$1 AND reprt_code='11011' + ORDER BY bsns_year DESC LIMIT 4 + """, code) + if fins: + ctx.append("· 재무추세(연간 사업보고서):") + for f in fins: + ctx.append( + f" - {f['bsns_year']}: 매출 {(f['revenue'] or 0)/1e8:,.0f}억 " + f"영업이익 {(f['operating_profit'] or 0)/1e8:,.0f}억 " + f"ROE {f['roe'] or 0:.1f}% 영업이익률 {f['operating_margin'] or 0:.1f}% " + f"부채비율 {f['debt_ratio'] or 0:.0f}% FCF {f['fcf_ratio'] or 0:.1f}% " + f"매출성장 {f['revenue_growth'] or 0:.1f}%") + else: + ctx.append("· 재무: DART 연간 재무데이터 없음 (가치판단 제약)") + + ta = None + if redis_cl: + try: + t = await redis_cl.get(f"ta:{code}") + if t: + ta = json.loads(t) + except Exception: + ta = None + if not ta: + trow = await conn.fetchrow(""" + SELECT price, ma20, ma60, rsi, macd, macd_signal, signal, + tech_score, targets + FROM stock_technical WHERE stock_code=$1 + """, code) + if trow: + ta = dict(trow) + if ta: + ctx.append( + f"· 기술적: 현재가 {ta.get('price') or 0:,}원 " + f"MA20 {ta.get('ma20') or 0:,.0f} MA60 {ta.get('ma60') or 0:,.0f} " + f"RSI {ta.get('rsi') or 0:.0f} 기술신호 [{ta.get('signal') or '-'}] " + f"기술점수 {ta.get('tech_score') or 0:.0f}") + tg = _jload(ta.get("targets")) + if tg and (tg.get("t1") or tg.get("stop_loss")): + meta["targets"] = tg + ctx.append( + f"· 기술적 목표가(참고): 진입 {tg.get('entry_price') or 0:,}원 " + f"T1 {tg.get('t1') or 0:,}원 T2 {tg.get('t2') or 0:,}원 " + f"T3 {tg.get('t3') or 0:,}원 손절 {tg.get('stop_loss') or 0:,}원") + + agg = await conn.fetchrow(""" + SELECT + COUNT(*) FILTER (WHERE sentiment='호재' AND analyzed_at>=NOW()-INTERVAL '14 days') p14, + COUNT(*) FILTER (WHERE sentiment='악재' AND analyzed_at>=NOW()-INTERVAL '14 days') n14, + COUNT(*) FILTER (WHERE sentiment='호재' AND analyzed_at>=NOW()-INTERVAL '30 days') p30, + COUNT(*) FILTER (WHERE sentiment='악재' AND analyzed_at>=NOW()-INTERVAL '30 days') n30 + FROM news_analysis + WHERE primary_stock=$1 AND reason != '파싱실패' + """, code) + if agg and (agg["p30"] or agg["n30"]): + ctx.append( + f"· 뉴스 흐름: 최근14일 호재 {agg['p14']}/악재 {agg['n14']}, " + f"30일 호재 {agg['p30']}/악재 {agg['n30']}") + + news = await conn.fetch(""" + SELECT analyzed_at::date d, sentiment, intensity, left(reason,90) reason + FROM news_analysis + WHERE primary_stock=$1 + AND reason != '파싱실패' AND sentiment IN ('호재','악재') + AND analyzed_at >= NOW()-INTERVAL '30 days' + ORDER BY analyzed_at DESC LIMIT 10 + """, code) + if news: + ctx.append("· 최근 뉴스 분석:") + for r in news: + ctx.append(f" - {r['d']} {r['sentiment']}/강도{r['intensity']} {r['reason']}") + + regime, _ = await calc_market_regime(conn) + ctx.append(f"· 시장 레짐: {regime}") + + return "\n".join(ctx), meta + + +_DEEP_SYSTEM = ( + "당신은 워렌 버핏 스타일의 한국 주식 가치투자 애널리스트입니다.\n" + "제공된 정량 데이터(퀀트 종합점수·학술공식 신호·재무추세·기술적·뉴스흐름)를 " + "종합해 매수/매도를 판단합니다.\n" + "판단 우선순위: 기업 본질가치(ROE·영업이익률·FCF·부채안정성·이익품질) > " + "밸류에이션(PER·PBR·DCF안전마진) > 뉴스 catalyst·모멘텀 > 단기 수급.\n" + "주어진 데이터에 근거해서만 판단하고 데이터에 없는 사실을 지어내지 마세요.\n" + "퀀트 시스템판정과 다른 결론을 내릴 경우 그 이유를 thesis에 명확히 쓰세요.\n" + "반드시 아래 스키마의 유효한 JSON 객체 하나만 출력하세요. 마크다운·설명문 금지." +) + +_DEEP_SCHEMA = ( + '{"recommendation":"강력매수|매수|중립|매도|강력매도",' + '"conviction":1~5 정수,' + '"thesis":"핵심 투자논거 2~3문장",' + '"key_points":["근거1","근거2","근거3"],' + '"risks":["리스크1","리스크2"],' + '"catalyst_watch":["향후 관전포인트1"],' + '"valuation_view":"저평가|적정|고평가",' + '"time_horizon":"단기|중기|장기",' + '"target_price":목표가_정수원(밸류에이션 근거 없으면 위 기술적 목표가 T2),' + '"stop_loss":손절가_정수원(근거 없으면 위 기술적 손절)}' +) + + +async def _exaone_json(client, system: str, user: str, max_tokens: int = 900) -> dict: + try: + r = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={ + "model": EXAONE_MODEL, + "messages": [{"role": "system", "content": system}, + {"role": "user", "content": user}], + "max_tokens": max_tokens, "temperature": 0.1}, timeout=180) + c = r.json()["choices"][0]["message"]["content"] + c = c.replace("```json", "").replace("```", "").strip() + if not c.startswith("{"): # 모델이 JSON 앞뒤에 설명을 붙인 경우 + s, e = c.find("{"), c.rfind("}") + if s != -1 and e > s: + c = c[s:e + 1] + return json.loads(c) + except Exception as e: + logger.warning("deep.exaone_err", error=str(e)) + return {} + + +def _norm_int(v) -> int: + try: + return int(float(v)) + except Exception: + return 0 + + +async def run_deep_analysis(conn, client, code: str, save: bool = True) -> dict: + ctx, meta = await _build_rag_context(conn, code) + if "재무: DART 연간 재무데이터 없음" in ctx and meta["quant_score"] == 0.0: + return {"code": code, "error": "데이터 부족 (재무·퀀트 모두 없음)"} + + user = (f"[분석 대상]\n{ctx}\n\n" + f"위 데이터를 종합해 버핏 가치투자 관점에서 매수/매도를 판단하세요.\n" + f"JSON 스키마:\n{_DEEP_SCHEMA}") + a = await _exaone_json(client, _DEEP_SYSTEM, user) + + rec = a.get("recommendation", "중립") + if rec not in _DEEP_RECS: + rec = "중립" + conv = max(0, min(5, _norm_int(a.get("conviction", 0)))) + tp, sl = _norm_int(a.get("target_price", 0)), _norm_int(a.get("stop_loss", 0)) + tg = meta.get("targets") or {} # LLM이 0 내면 ta-engine 목표가로 폴백 + if tp <= 0: + tp = _norm_int(tg.get("t2") or tg.get("t1") or 0) + if sl <= 0: + sl = _norm_int(tg.get("stop_loss") or 0) + thesis = str(a.get("thesis", "") or "")[:1000] + parsed_ok = bool(a) + + def _clean(lst): + return [str(x).lstrip("-*•· ").strip() + for x in (lst or []) if str(x).strip()][:6] + + report = { + "recommendation": rec, "conviction": conv, + "thesis": thesis, + "key_points": _clean(a.get("key_points")), + "risks": _clean(a.get("risks")), + "catalyst_watch": _clean(a.get("catalyst_watch")), + "valuation_view": a.get("valuation_view", "-"), + "time_horizon": a.get("time_horizon", "-"), + "target_price": tp, "stop_loss": sl, + "parsed_ok": parsed_ok, + } + + if save and parsed_ok: + await conn.execute(""" + INSERT INTO deep_analysis ( + stock_code, stock_name, analysis_date, recommendation, conviction, + target_price, stop_loss, thesis, report, rag_context, quant_score) + VALUES ($1,$2,CURRENT_DATE,$3,$4,$5,$6,$7,$8,$9,$10) + ON CONFLICT (stock_code, analysis_date) DO UPDATE SET + recommendation=EXCLUDED.recommendation, conviction=EXCLUDED.conviction, + target_price=EXCLUDED.target_price, stop_loss=EXCLUDED.stop_loss, + thesis=EXCLUDED.thesis, report=EXCLUDED.report, + rag_context=EXCLUDED.rag_context, quant_score=EXCLUDED.quant_score, + created_at=NOW() + """, code, meta["name"], rec, conv, tp, sl, thesis, + json.dumps(report, ensure_ascii=False), ctx, meta["quant_score"]) + + return { + "code": code, "name": meta["name"], + "quant_score": meta["quant_score"], "quant_rec": meta["quant_rec"], + **report, + } + + +def _fmt_deep(r: dict) -> str: + if r.get("error"): + return f"⚠️ {r['code']}: {r['error']}" + icon = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "⚪", + "매도": "🔴", "강력매도": "🔴🔴"}.get(r["recommendation"], "⚪") + lines = [ + f"{icon} {r['name']}({r['code']}) — AI심층분석", + f"판단: {r['recommendation']} (확신도 {r['conviction']}/5) " + f"· 퀀트 {r['quant_score']:.0f}점[{r['quant_rec']}]", + f"밸류: {r.get('valuation_view','-')} · 기간: {r.get('time_horizon','-')}", + "", + f"📝 투자논거\n{r['thesis']}", + ] + if r.get("key_points"): + lines.append("\n✅ 핵심근거") + lines += [f" • {p}" for p in r["key_points"][:5]] + if r.get("risks"): + lines.append("\n⚠️ 리스크") + lines += [f" • {p}" for p in r["risks"][:4]] + if r.get("catalyst_watch"): + lines.append("\n👀 관전포인트") + lines += [f" • {p}" for p in r["catalyst_watch"][:3]] + tp, sl = r.get("target_price", 0), r.get("stop_loss", 0) + if tp or sl: + lines.append(f"\n🎯 목표가 {tp:,}원 · 손절가 {sl:,}원") + lines.append("\n※ 투자 판단·책임은 본인에게 있습니다") + return "\n".join(lines) + + +@app.get("/deep-analysis/{code}") +async def deep_analysis(code: str, + refresh: bool = Query(default=True), + notify: bool = Query(default=False), + include_context: bool = Query(default=False)): + """RAG + EXAONE 종목 심층분석 (온디맨드). refresh=false면 당일 저장본 반환.""" + async with pg_pool.acquire() as conn: + if not refresh: + cached = await conn.fetchrow(""" + SELECT stock_name, recommendation, conviction, target_price, + stop_loss, thesis, report, quant_score, rag_context + FROM deep_analysis + WHERE stock_code=$1 AND analysis_date=CURRENT_DATE + """, code) + if cached: + rep = _jload(cached["report"]) + out = {"code": code, "name": cached["stock_name"], + "quant_score": float(cached["quant_score"] or 0), + "quant_rec": "-", "cached": True, **rep} + if include_context: + out["rag_context"] = cached["rag_context"] + if notify: + await send_telegram(_fmt_deep(out)) + return out + async with httpx.AsyncClient() as client: + res = await run_deep_analysis(conn, client, code, save=True) + if include_context and not res.get("error"): + async with pg_pool.acquire() as conn: + res["rag_context"], _ = await _build_rag_context(conn, code) + if notify and not res.get("error"): + await send_telegram(_fmt_deep(res)) + return res + + +@app.post("/deep-analysis/batch") +async def deep_analysis_batch(kinds: str = Query(default="강력매수,매수관심"), + limit: int = Query(default=10, ge=1, le=30), + notify: bool = Query(default=True)): + """최신 score_date 추천 종목을 일괄 심층분석 + 텔레그램 다이제스트""" + kind_list = [k.strip() for k in kinds.split(",") if k.strip()] + sell_only = all(k in ("매도관심", "강력매도") for k in kind_list) + order = "ASC" if sell_only else "DESC" + async with pg_pool.acquire() as conn: + rows = await conn.fetch(f""" + SELECT stock_code FROM stock_scores + WHERE score_date=(SELECT MAX(score_date) FROM stock_scores) + AND recommendation = ANY($1::text[]) + ORDER BY total_score {order} LIMIT $2 + """, kind_list, limit) + codes = [r["stock_code"] for r in rows] + results = [] + async with httpx.AsyncClient() as client: + for c in codes: + results.append(await run_deep_analysis(conn, client, c, save=True)) + ok = [r for r in results if not r.get("error")] + if notify and ok: + head = f"🧠 AI 심층분석 다이제스트 ({date.today()})\n대상 {len(ok)}종목\n" + digest = [head] + for r in ok: + ic = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "⚪", + "매도": "🔴", "강력매도": "🔴🔴"}.get(r["recommendation"], "⚪") + digest.append(f"{ic} {r['name']}({r['code']}) " + f"{r['recommendation']} 확신{r['conviction']}/5 " + f"· 퀀트{r['quant_score']:.0f}") + await send_telegram("\n".join(digest)) + return {"analyzed": len(codes), "ok": len(ok), + "results": [{"code": r["code"], "name": r.get("name"), + "recommendation": r.get("recommendation"), + "conviction": r.get("conviction"), + "error": r.get("error")} for r in results]} + + +async def deep_analysis_batch_job(): + """평일 17:00 — 당일 추천종목 자동 심층분석 (16:30 스코어링 이후)""" + try: + await deep_analysis_batch(kinds="강력매수,매수관심", limit=10, notify=True) + logger.info("deep_batch.done") + except Exception as e: + logger.error("deep_batch.err", error=str(e)) diff --git a/score-engine/requirements.txt b/score-engine/requirements.txt new file mode 100644 index 0000000..5f8e0da --- /dev/null +++ b/score-engine/requirements.txt @@ -0,0 +1,13 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +httpx==0.27.0 +asyncpg==0.29.0 +redis==5.0.4 +apscheduler==3.10.4 +orjson==3.10.3 +structlog==24.2.0 +scikit-learn==1.5.0 +numpy==1.26.4 +xgboost==2.0.3 +arch==7.0.0 +scipy==1.13.1 diff --git a/scripts/setup-postgres-local.sh b/scripts/setup-postgres-local.sh new file mode 100755 index 0000000..76dd678 --- /dev/null +++ b/scripts/setup-postgres-local.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# PostgreSQL 로컬 설치 + 데이터 경로를 시놀로지 NAS(/mnt/nas)에 설정 +# 실행: sudo bash setup-postgres-local.sh + +set -e + +NAS_HOST="192.168.0.36" +NAS_NFS_PATH="/volume1/trading" +NFS_MOUNT_LOCAL="/mnt/nas" +PG_DATA_DIR="${NFS_MOUNT_LOCAL}/postgresql/data" +PG_VERSION="16" +PG_USER="kyu" +PG_PASSWORD="7895123" +PG_DB="trading_ai" + +echo "==============================" +echo "1. NFS 클라이언트 설치 확인" +echo "==============================" +apt-get install -y nfs-common 2>/dev/null || true + +echo "" +echo "==============================" +echo "2. NAS NFS 마운트" +echo "==============================" +mkdir -p "${NFS_MOUNT_LOCAL}" + +# 이미 마운트 되어있으면 스킵 +if mountpoint -q "${NFS_MOUNT_LOCAL}"; then + echo "이미 마운트됨: ${NFS_MOUNT_LOCAL}" +else + mount -t nfs "${NAS_HOST}:${NAS_NFS_PATH}" "${NFS_MOUNT_LOCAL}" -o rw,hard,intr,timeo=30,retrans=3 + echo "마운트 완료: ${NAS_HOST}:${NAS_NFS_PATH} -> ${NFS_MOUNT_LOCAL}" +fi + +# /etc/fstab에 자동 마운트 등록 (없으면 추가) +FSTAB_ENTRY="${NAS_HOST}:${NAS_NFS_PATH} ${NFS_MOUNT_LOCAL} nfs rw,hard,intr,timeo=30,retrans=3,_netdev 0 0" +if ! grep -qF "${NFS_MOUNT_LOCAL}" /etc/fstab; then + echo "${FSTAB_ENTRY}" >> /etc/fstab + echo "fstab에 자동 마운트 추가됨" +fi + +echo "" +echo "==============================" +echo "3. PostgreSQL 데이터 디렉토리 생성" +echo "==============================" +mkdir -p "${PG_DATA_DIR}" +chown postgres:postgres "${PG_DATA_DIR}" +chmod 700 "${PG_DATA_DIR}" +echo "데이터 경로: ${PG_DATA_DIR}" + +echo "" +echo "==============================" +echo "4. PostgreSQL 서비스 설정" +echo "==============================" + +# 기존 클러스터 중지 및 제거 +pg_ctlcluster ${PG_VERSION} main stop 2>/dev/null || true + +# 기존 기본 데이터 디렉토리를 NAS 경로로 교체 +CONF_FILE="/etc/postgresql/${PG_VERSION}/main/postgresql.conf" +if [ -f "${CONF_FILE}" ]; then + # data_directory 변경 + sed -i "s|^data_directory.*|data_directory = '${PG_DATA_DIR}'|" "${CONF_FILE}" + # 이미 변경된 경우가 아니면 추가 + if ! grep -q "^data_directory" "${CONF_FILE}"; then + echo "data_directory = '${PG_DATA_DIR}'" >> "${CONF_FILE}" + fi + echo "postgresql.conf 업데이트 완료" +fi + +echo "" +echo "==============================" +echo "5. 데이터 디렉토리 초기화 (최초 1회)" +echo "==============================" +if [ ! -f "${PG_DATA_DIR}/PG_VERSION" ]; then + sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/initdb \ + -D "${PG_DATA_DIR}" \ + --encoding=UTF8 \ + --locale=ko_KR.UTF-8 \ + --auth=md5 2>/dev/null || \ + sudo -u postgres /usr/lib/postgresql/${PG_VERSION}/bin/initdb \ + -D "${PG_DATA_DIR}" \ + --encoding=UTF8 \ + --locale=C.UTF-8 \ + --auth=md5 + echo "DB 초기화 완료" +else + echo "이미 초기화된 데이터 디렉토리 (스킵)" +fi + +echo "" +echo "==============================" +echo "6. 외부 접속 허용 설정" +echo "==============================" +# postgresql.conf - 모든 인터페이스 리슨 +sed -i "s|^#listen_addresses.*|listen_addresses = '*'|; s|^listen_addresses.*|listen_addresses = '*'|" "${PG_DATA_DIR}/postgresql.conf" 2>/dev/null || \ + echo "listen_addresses = '*'" >> "${PG_DATA_DIR}/postgresql.conf" + +# pg_hba.conf - Docker 네트워크(172.30.0.0/16) 접속 허용 +PG_HBA="${PG_DATA_DIR}/pg_hba.conf" +if ! grep -q "172.30.0.0/16" "${PG_HBA}" 2>/dev/null; then + cat >> "${PG_HBA}" << 'HBA' +# Trading AI Docker 네트워크 +host all all 172.30.0.0/16 md5 +host all all 127.0.0.1/32 md5 +host all all ::1/128 md5 +HBA + echo "pg_hba.conf 업데이트 완료" +fi + +echo "" +echo "==============================" +echo "7. PostgreSQL 서비스 시작" +echo "==============================" +systemctl start postgresql +systemctl enable postgresql +sleep 3 +systemctl is-active postgresql && echo "PostgreSQL 실행 중" + +echo "" +echo "==============================" +echo "8. 유저 및 DB 생성" +echo "==============================" +sudo -u postgres psql -c "DO \$\$ BEGIN + IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = '${PG_USER}') THEN + CREATE USER ${PG_USER} WITH SUPERUSER PASSWORD '${PG_PASSWORD}'; + ELSE + ALTER USER ${PG_USER} WITH PASSWORD '${PG_PASSWORD}'; + END IF; +END \$\$;" + +sudo -u postgres psql -c "CREATE DATABASE ${PG_DB} OWNER ${PG_USER};" 2>/dev/null || \ + echo "DB ${PG_DB} 이미 존재 (스킵)" + +echo "" +echo "==============================" +echo "완료! PostgreSQL 로컬 실행 중" +echo " Host: localhost (또는 127.0.0.1)" +echo " Port: 5432" +echo " DB: ${PG_DB}" +echo " User: ${PG_USER}" +echo " Data: ${PG_DATA_DIR} (NAS)" +echo "==============================" +echo "" +echo "다음 단계: 덤프 임포트" +echo " PGPASSWORD=${PG_PASSWORD} psql -h 127.0.0.1 -U ${PG_USER} -d ${PG_DB} -f /home/kyu/trading/pg_backup/trading_ai_backup.sql" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..3b86b5d --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# ============================================================ +# Trading AI - Setup Script +# ============================================================ +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +log() { echo -e "${GREEN}[✓]${NC} $1"; } +warn() { echo -e "${YELLOW}[!]${NC} $1"; } +err() { echo -e "${RED}[✗]${NC} $1" >&2; exit 1; } + +[ -f .env ] && source .env || err ".env 파일이 없습니다." + +echo "============================================================" +echo " Trading AI System - Setup" +echo "============================================================" + +# ── 1. Docker / NVIDIA 확인 ──────────────────────────────── +command -v docker >/dev/null 2>&1 || err "Docker가 없습니다." + +if ! command -v nvidia-smi >/dev/null 2>&1; then + err "NVIDIA 드라이버가 없습니다." +fi + +if ! docker info 2>/dev/null | grep -q "Runtimes.*nvidia"; then + warn "nvidia-container-toolkit 설치 중..." + curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \ + sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \ + sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \ + sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list + sudo apt-get update -qq + sudo apt-get install -y nvidia-container-toolkit + sudo nvidia-ctk runtime configure --runtime=docker + sudo systemctl restart docker + log "nvidia-container-toolkit 설치 완료" +fi + +log "GPU 정보:" +nvidia-smi --query-gpu=index,name,memory.total --format=csv,noheader | \ + while IFS=, read -r idx name mem; do echo " GPU $idx: $name ($mem)"; done + +# ── 2. 시스템 최적화 ──────────────────────────────────────── +if ! grep -q "vm.overcommit_memory" /etc/sysctl.conf 2>/dev/null; then + printf "vm.overcommit_memory = 1\nvm.swappiness = 10\nnet.core.somaxconn = 65535\n" | \ + sudo tee -a /etc/sysctl.conf + sudo sysctl -p >/dev/null 2>&1 + log "커널 파라미터 최적화" +fi +echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled >/dev/null + +# ── 3. NFS 마운트 (스킵 - 나중에 수동 설정) ─────────────── +warn "NFS 마운트 스킵 (Synology 설정 후 수동으로 진행하세요)" +sudo mkdir -p /mnt/nas/news /mnt/nas/models /mnt/nas/backups +# ── 4. 기존 네트워크 정리 ────────────────────────────────── +log "기존 컨테이너/네트워크 정리 중..." +docker compose down --remove-orphans 2>/dev/null || true +docker network prune -f >/dev/null 2>&1 || true + +# ── 5. 순차 시작 ─────────────────────────────────────────── +log "1단계: Redis, Qdrant, Bareun 시작..." +docker compose up -d --build redis qdrant bareun + +log "헬스체크 대기 (20초)..." +sleep 20 + +log "2단계: bareunaapi 시작..." +docker compose up -d --build bareunaapi + +log "3단계: Ollama 시작 (GPU 1 - RTX 3070)..." +docker compose up -d ollama + +log "BGE-M3 모델 다운로드 중..." +sleep 30 +docker exec trading-ollama ollama pull bge-m3 2>/dev/null || \ + warn "BGE-M3 수동 다운로드 필요: docker exec trading-ollama ollama pull bge-m3" + +log "4단계: vLLM 시작 (GPU 0 - RTX 3060, 모델 로딩 2~5분)..." +docker compose up -d vllm + +log "5단계: n8n 시작..." +docker compose up -d n8n n8n-worker + +# ── 6. Qdrant 컬렉션 초기화 ──────────────────────────────── +log "Qdrant 컬렉션 초기화 중..." +sleep 10 +HTTP=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:6333/collections/news_vectors) +if [ "${HTTP}" = "404" ]; then + curl -s -X PUT http://localhost:6333/collections/news_vectors \ + -H "Content-Type: application/json" \ + -d '{ + "vectors": {"size": 1024, "distance": "Cosine"}, + "optimizers_config": {"default_segment_number": 4}, + "on_disk_payload": false + }' >/dev/null + log "Qdrant 컬렉션 'news_vectors' 생성" +else + warn "Qdrant 컬렉션 이미 존재" +fi + +echo "" +echo "============================================================" +echo -e "${GREEN} 시작 완료!${NC}" +echo "============================================================" +echo " n8n : http://localhost:5678" +echo " Qdrant : http://localhost:6333/dashboard" +echo " vLLM : http://localhost:8000/docs" +echo " Ollama : http://localhost:11434" +echo " 바른API : http://localhost:5757/docs" +echo "============================================================" +warn "vLLM 모델 로딩 확인: docker compose logs -f vllm" diff --git a/scripts/status.sh b/scripts/status.sh new file mode 100755 index 0000000..78dfda0 --- /dev/null +++ b/scripts/status.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +[ -f .env ] && source .env +GREEN='\033[0;32m'; RED='\033[0;31m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m' + +chk_http() { + local name=$1 url=$2 + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 4 "$url" 2>/dev/null || echo "000") + [[ "$code" =~ ^(200|204)$ ]] \ + && echo -e " ${GREEN}●${NC} $name" \ + || echo -e " ${RED}●${NC} $name (HTTP $code)" +} + +chk_container() { + local name=$1 + status=$(docker inspect --format='{{.State.Status}}' "$name" 2>/dev/null || echo "없음") + [ "$status" = "running" ] \ + && echo -e " ${GREEN}●${NC} $name" \ + || echo -e " ${RED}●${NC} $name ($status)" +} + +echo "" +echo -e "${BLUE}══ Trading AI Status ══════════════════════${NC} $(date '+%Y-%m-%d %H:%M:%S')" + +echo -e "\n${BLUE}[컨테이너]${NC}" +for c in trading-redis trading-qdrant trading-bareun trading-bareunaapi \ + trading-ollama trading-vllm trading-n8n trading-n8n-worker; do + chk_container "$c" +done + +echo -e "\n${BLUE}[서비스]${NC}" +chk_http "Redis" "http://localhost:6379" +chk_http "Qdrant" "http://localhost:6333/healthz" +chk_http "Bareun" "http://localhost:9902/health" +chk_http "바른API" "http://localhost:5757/health" +chk_http "Ollama" "http://localhost:11434/api/tags" +chk_http "vLLM" "http://localhost:8000/health" +chk_http "n8n" "http://localhost:5678/healthz" + +echo -e "\n${BLUE}[GPU]${NC}" +nvidia-smi --query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu \ + --format=csv,noheader 2>/dev/null | \ + while IFS=, read -r i n u mu mt t; do + echo " GPU$i $n | 사용률:$u | VRAM:$mu/$mt | 온도:$t" + done + +echo -e "\n${BLUE}[NFS]${NC}" +mountpoint -q /mnt/nas 2>/dev/null \ + && echo -e " ${GREEN}●${NC} /mnt/nas (마운트됨)" \ + || echo -e " ${RED}●${NC} /mnt/nas (마운트 안됨)" + +echo "" diff --git a/setup-all.sh b/setup-all.sh new file mode 100644 index 0000000..4382526 --- /dev/null +++ b/setup-all.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# ============================================================ +# Phase 1~4 전체 설치 스크립트 +# ============================================================ +set -euo pipefail + +GREEN='\033[0;32m'; NC='\033[0m' +log() { echo -e "${GREEN}[✓]${NC} $1"; } + +cd ~/trading + +# ── 1. 디렉토리 생성 ─────────────────────────────────── +log "디렉토리 생성..." +mkdir -p news-collector kis-api score-engine dashboard-api + +# ── 2. .env 추가 ──────────────────────────────────────── +log ".env 업데이트..." +grep -q "KIS_APP_KEY" .env 2>/dev/null || cat >> .env << 'EOF' + +# Phase 2: 한국투자증권 (없으면 빈값) +KIS_APP_KEY= +KIS_APP_SECRET= +KIS_ACCOUNT_NO= +KIS_IS_PAPER=true +EOF + +# ── 3. 빌드 및 시작 ───────────────────────────────────── +log "Phase 1: 뉴스 수집기 빌드..." +docker compose up -d --build news-collector + +log "Phase 2: KIS API 빌드..." +docker compose up -d --build kis-api + +log "Phase 3: 점수 엔진 빌드..." +docker compose up -d --build score-engine + +log "Phase 4: 대시보드 API 빌드..." +docker compose up -d --build dashboard-api + +# ── 4. 헬스체크 ────────────────────────────────────────── +log "서비스 시작 대기 (30초)..." +sleep 30 + +echo "" +echo "============================================================" +echo -e "${GREEN} Phase 1~4 설치 완료!${NC}" +echo "============================================================" +echo "" +echo " 서비스 목록:" +echo " ─────────────────────────────────────────" +echo " 뉴스 수집기 : http://192.168.0.60:8787" +echo " KIS 주가 API : http://192.168.0.60:8585" +echo " 점수 엔진 : http://192.168.0.60:8686" +echo " 대시보드 API : http://192.168.0.60:8989" +echo " DART 수집기 : http://192.168.0.60:8888" +echo " n8n : http://192.168.0.60:5678" +echo " 바른API : http://192.168.0.60:5757" +echo " vLLM : http://192.168.0.60:8000" +echo "" +echo " 사용법:" +echo " ─────────────────────────────────────────" +echo " # 뉴스 수동 수집" +echo " curl -s -X POST http://localhost:8787/collect/market" +echo " curl -s -X POST http://localhost:8787/collect/stocks" +echo "" +echo " # 점수 수동 산출" +echo " curl -s -X POST http://localhost:8686/score/calculate" +echo "" +echo " # 추천 종목 조회" +echo " curl -s http://localhost:8686/recommendations | python3 -m json.tool" +echo "" +echo " # 종목 랭킹" +echo " curl -s http://localhost:8686/ranking | python3 -m json.tool" +echo "" +echo " # 종목 상세 (삼성전자)" +echo " curl -s http://localhost:8686/stock/005930 | python3 -m json.tool" +echo "" +echo " # 대시보드 API" +echo " curl -s http://localhost:8989/api/summary | python3 -m json.tool" +echo " curl -s http://localhost:8989/api/ranking | python3 -m json.tool" +echo " curl -s http://localhost:8989/api/recommendations | python3 -m json.tool" +echo "============================================================" + +docker ps -a --format "table {{.Names}}\t{{.Status}}" diff --git a/stock_loader.py b/stock_loader.py new file mode 100644 index 0000000..0d79394 --- /dev/null +++ b/stock_loader.py @@ -0,0 +1,162 @@ +""" +KRX 전체 종목 동적 로딩 +- 서버 시작 시 1회 로딩 +- 매일 자정 자동 갱신 +- 실패 시 하드코딩 폴백 +""" + +import asyncio +import json +import io +import time +from datetime import datetime + +import httpx +import structlog + +logger = structlog.get_logger() + +# 폴백 종목 (네트워크 실패 시 사용) +FALLBACK_STOCKS = { + "코스피": "KOSPI", "코스닥": "KOSDAQ", "코스피200": "KOSPI200", + "삼성전자": "005930", "SK하이닉스": "000660", "LG에너지솔루션": "373220", + "현대차": "005380", "기아": "000270", "셀트리온": "068270", + "카카오": "035720", "네이버": "035420", "NAVER": "035420", + "삼성바이오로직스": "207940", "KB금융": "105560", "POSCO홀딩스": "005490", + "신한지주": "055550", "LG화학": "051910", "삼성SDI": "006400", + "현대모비스": "012330", "하나금융지주": "086790", "SK텔레콤": "017670", + "KT": "030200", "LG전자": "066570", "한화에어로스페이스": "012450", + "삼성물산": "028260", "HD현대중공업": "329180", "한국전력": "015760", + "HMM": "011200", "대한항공": "003490", "카카오뱅크": "323410", + "크래프톤": "259960", "하이브": "352820", "에코프로": "086520", + "알테오젠": "196170", "한미반도체": "042700", +} + +# 약칭/별칭 +ALIASES = { + "삼전": "005930", "하닉": "000660", "현차": "005380", + "카뱅": "323410", "삼바": "207940", "삼성바이오": "207940", + "한에솔": "012450", "한화에어": "012450", "LG엔솔": "373220", + "SK하닉": "000660", "포홀": "005490", "에프엔에프": "383220", + "현대자동차": "005380", "네이버": "035420", +} + + +async def fetch_krx_stocks() -> dict[str, str]: + """KRX에서 전체 상장 종목 가져오기""" + stock_map = { + "코스피": "KOSPI", "코스닥": "KOSDAQ", + "코스피200": "KOSPI200", "KRX": "KRX", + } + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "http://data.krx.co.kr/contents/MDC/MDI/mdiLoader/index.cmd", + } + + async with httpx.AsyncClient(timeout=30, headers=headers) as client: + for mkt_id, mkt_name in [("STK", "KOSPI"), ("KSQ", "KOSDAQ")]: + try: + payload = { + "bld": "dbms/MDC/STAT/standard/MDCSTAT01901", + "locale": "ko_KR", + "mktId": mkt_id, + "share": "1", + "csvxls_is498": "false", + } + resp = await client.post( + "http://data.krx.co.kr/comm/bldAttend/getJsonData.cmd", + data=payload, + ) + if resp.status_code == 200: + data = resp.json() + items = data.get("OutBlock_1", []) + for item in items: + name = item.get("ISU_ABBRV", "").strip() + code = item.get("ISU_SRT_CD", "").strip() + if name and code and len(code) == 6: + stock_map[name] = code + logger.info("krx.loaded", market=mkt_name, count=len(items)) + else: + logger.warning("krx.http_error", market=mkt_name, status=resp.status_code) + except Exception as e: + logger.warning("krx.fetch_failed", market=mkt_name, error=str(e)) + + # KRX 실패 시 네이버 금융 폴백 + if len(stock_map) < 100: + logger.info("krx.fallback_naver", reason="KRX returned too few stocks") + stock_map.update(await fetch_naver_stocks(client=None)) + + return stock_map + + +async def fetch_naver_stocks() -> dict[str, str]: + """네이버 금융에서 종목 가져오기 (KRX 폴백)""" + stock_map = {} + headers = {"User-Agent": "Mozilla/5.0"} + import re + + async with httpx.AsyncClient(timeout=15, headers=headers) as client: + for sosok in [0, 1]: # 0=KOSPI, 1=KOSDAQ + for page in range(1, 45): + try: + url = f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}" + resp = await client.get(url) + text = resp.content.decode("euc-kr", errors="ignore") + rows = re.findall( + r"main\.naver\?code=(\d{6})[^>]*>([^<]+)", text + ) + if not rows: + break + for code, name in rows: + name = name.strip() + if name and code: + stock_map[name] = code + except Exception: + break + + logger.info("naver.loaded", count=len(stock_map)) + return stock_map + + +async def load_all_stocks() -> dict[str, str]: + """전체 종목 로딩 (KRX → 네이버 → 폴백)""" + try: + stock_map = await fetch_krx_stocks() + if len(stock_map) > 100: + stock_map.update(ALIASES) + logger.info("stocks.loaded", total=len(stock_map), source="KRX") + return stock_map + except Exception as e: + logger.warning("stocks.krx_failed", error=str(e)) + + try: + stock_map = await fetch_naver_stocks() + if len(stock_map) > 100: + stock_map.update({ + "코스피": "KOSPI", "코스닥": "KOSDAQ", + "코스피200": "KOSPI200", "KRX": "KRX", + }) + stock_map.update(ALIASES) + logger.info("stocks.loaded", total=len(stock_map), source="Naver") + return stock_map + except Exception as e: + logger.warning("stocks.naver_failed", error=str(e)) + + # 최종 폴백 + fallback = {**FALLBACK_STOCKS, **ALIASES} + logger.warning("stocks.using_fallback", total=len(fallback)) + return fallback + + +async def auto_refresh_stocks(state_ref, interval_hours: int = 24): + """백그라운드에서 종목 목록 자동 갱신""" + while True: + await asyncio.sleep(interval_hours * 3600) + try: + new_map = await load_all_stocks() + if len(new_map) > 50: + state_ref.stock_map = new_map + logger.info("stocks.refreshed", total=len(new_map)) + except Exception as e: + logger.warning("stocks.refresh_failed", error=str(e)) diff --git a/ta-engine/Dockerfile b/ta-engine/Dockerfile new file mode 100644 index 0000000..53982ed --- /dev/null +++ b/ta-engine/Dockerfile @@ -0,0 +1,6 @@ +FROM python:3.11-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8484", "--workers", "1"] diff --git a/ta-engine/main.py b/ta-engine/main.py new file mode 100644 index 0000000..6b01a92 --- /dev/null +++ b/ta-engine/main.py @@ -0,0 +1,965 @@ +""" +기술적 분석 엔진 (Technical Analysis Engine) +- 네이버 금융 차트 API (1차) / yfinance (2차 백업) OHLCV 수집 +- MA5/20/60/120, RSI(14), MACD(12,26,9), 볼린저밴드(20,2), 스토캐스틱(14,3) +- 기술적 점수 (-100~100) 산출 +- 매수/매도 목표가 T1/T2/T3 + 손절가 자동 계산 +- vLLM AI 문장형 판단 생성 +- 보유 포지션 손익 + 맞춤 전략 분석 +""" +import asyncio, json, os, re, math +from datetime import datetime +from typing import Optional, List, Dict +import asyncpg, httpx, redis.asyncio as aioredis, structlog +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from fastapi import FastAPI, Query, Body +from fastapi.responses import JSONResponse +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel + +structlog.configure(processors=[ + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.add_log_level, + structlog.processors.JSONRenderer(), +]) +logger = structlog.get_logger() + +REDIS_HOST = os.getenv("REDIS_HOST", "redis") +REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "") +PG_HOST = os.getenv("POSTGRES_HOST", "postgres") +PG_PORT = int(os.getenv("POSTGRES_PORT", "5432")) +PG_DB = os.getenv("POSTGRES_DB", "trading_ai") +PG_USER = os.getenv("POSTGRES_USER", "kyu") +PG_PASS = os.getenv("POSTGRES_PASSWORD", "") +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") +HEADERS = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"} + +pg_pool: Optional[asyncpg.Pool] = None +redis_cl: Optional[aioredis.Redis] = None +scheduler = AsyncIOScheduler(timezone="Asia/Seoul") + +class Stats: + analyzed = 0; errors = 0; last_run = "" +stats = Stats() + +# ── 기술적 지표 계산 ────────────────────────────────────── + +def _ema_series(values: List[float], period: int) -> List[float]: + if not values or len(values) < period: + return [values[-1]] * len(values) if values else [] + k = 2.0 / (period + 1) + seed = sum(values[:period]) / period + out = [seed] + for v in values[period:]: + out.append(v * k + out[-1] * (1 - k)) + return [out[0]] * (period - 1) + out + +def _ma(closes: List[float], n: int) -> float: + if not closes: return 0.0 + data = closes[-n:] if len(closes) >= n else closes + return sum(data) / len(data) + +def _rsi(closes: List[float], period: int = 14) -> float: + if len(closes) < period + 1: + return 50.0 + deltas = [closes[i] - closes[i-1] for i in range(1, len(closes))] + gains = [max(d, 0.0) for d in deltas] + losses = [max(-d, 0.0) for d in deltas] + ag = sum(gains[:period]) / period + al = sum(losses[:period]) / period + for i in range(period, len(gains)): + ag = (ag * (period - 1) + gains[i]) / period + al = (al * (period - 1) + losses[i]) / period + if al == 0: + return 100.0 + return round(100 - 100 / (1 + ag / al), 2) + +def _macd(closes: List[float]) -> tuple: + if len(closes) < 26: + return 0.0, 0.0, 0.0 + e12 = _ema_series(closes, 12) + e26 = _ema_series(closes, 26) + macd_line = [a - b for a, b in zip(e12, e26)] + signal_line = _ema_series(macd_line[-9:] if len(macd_line) >= 9 else macd_line, 9) + macd = macd_line[-1] + signal = signal_line[-1] + return round(macd, 4), round(signal, 4), round(macd - signal, 4) + +def _bollinger(closes: List[float], period: int = 20) -> tuple: + if len(closes) < period: + c = closes[-1] if closes else 0 + return float(c), float(c), float(c), 0.5 + recent = closes[-period:] + ma = sum(recent) / period + std = math.sqrt(sum((x - ma) ** 2 for x in recent) / period) + upper = ma + 2 * std + lower = ma - 2 * std + cur = closes[-1] + pct_b = (cur - lower) / (upper - lower) if upper != lower else 0.5 + return round(upper), round(ma), round(lower), round(pct_b, 3) + +def _stochastic(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> tuple: + if len(closes) < period: + return 50.0, 50.0 + h = max(highs[-period:]) + l = min(lows[-period:]) + k = ((closes[-1] - l) / (h - l) * 100) if h != l else 50.0 + ks = [] + for i in range(3): + idx = -(3 - i) + hh = max(highs[idx - period + 1:idx + 1] if idx != -1 else highs[-period:]) + ll = min(lows[idx - period + 1:idx + 1] if idx != -1 else lows[-period:]) + ks.append(((closes[idx] - ll) / (hh - ll) * 100) if hh != ll else 50.0) + d = sum(ks) / len(ks) + return round(k, 2), round(d, 2) + +def _vol_ratio(volumes: List[float], period: int = 20) -> float: + if len(volumes) < period + 1: + return 1.0 + avg = sum(volumes[-period - 1:-1]) / period + return round(volumes[-1] / avg, 2) if avg > 0 else 1.0 + +def _atr(highs: List[float], lows: List[float], closes: List[float], period: int = 14) -> float: + """Average True Range — 변동성 측정""" + if len(closes) < period + 1: + return 0.0 + trs = [] + for i in range(1, len(closes)): + tr = max( + highs[i] - lows[i], + abs(highs[i] - closes[i-1]), + abs(lows[i] - closes[i-1]), + ) + trs.append(tr) + recent = trs[-period:] + return round(sum(recent) / period, 2) if recent else 0.0 + + +def _obv(closes: List[float], volumes: List[float]) -> tuple: + """On-Balance Volume — 거래량 누적, 가격 상승일 +volume, 하락일 -volume""" + if len(closes) < 2: return 0, 0 + obv = 0.0 + obvs = [0.0] + for i in range(1, len(closes)): + if closes[i] > closes[i-1]: obv += volumes[i] + elif closes[i] < closes[i-1]: obv -= volumes[i] + obvs.append(obv) + # 최근 OBV 추세 (20일 평균 대비) + recent = obvs[-20:] if len(obvs) >= 20 else obvs + avg = sum(recent) / len(recent) if recent else 0 + obv_trend = "상승" if obv > avg * 1.05 else ("하락" if obv < avg * 0.95 else "중립") + return int(obv), obv_trend + + +def _vwap(highs: List[float], lows: List[float], closes: List[float], volumes: List[float], + period: int = 20) -> float: + """Volume Weighted Average Price — 거래량 가중 평균가, 기관 매매 기준선""" + if len(closes) < period: return float(closes[-1]) if closes else 0 + cum_vp = sum(((highs[i] + lows[i] + closes[i]) / 3) * volumes[i] + for i in range(-period, 0)) + cum_v = sum(volumes[-period:]) + return round(cum_vp / cum_v, 2) if cum_v > 0 else 0 + + +def _ichimoku(highs: List[float], lows: List[float], closes: List[float]) -> dict: + """일목균형표 (Ichimoku Kinko Hyo) — 5개 라인""" + if len(closes) < 52: return {} + # 전환선(Tenkan-sen): (9일 고가 + 9일 저가) / 2 + tenkan = (max(highs[-9:]) + min(lows[-9:])) / 2 + # 기준선(Kijun-sen): (26일 고가 + 26일 저가) / 2 + kijun = (max(highs[-26:]) + min(lows[-26:])) / 2 + # 선행스팬1(Senkou Span A): (전환+기준)/2, 26일 후 + span_a = (tenkan + kijun) / 2 + # 선행스팬2(Senkou Span B): (52일 고가 + 52일 저가)/2, 26일 후 + span_b = (max(highs[-52:]) + min(lows[-52:])) / 2 + # 후행스팬(Chikou Span): 종가, 26일 전 + chikou = closes[-26] if len(closes) > 26 else closes[-1] + cur = closes[-1] + cloud_top = max(span_a, span_b) + cloud_bot = min(span_a, span_b) + pos = "구름위" if cur > cloud_top else ("구름아래" if cur < cloud_bot else "구름속") + return { + "tenkan": int(tenkan), "kijun": int(kijun), + "span_a": int(span_a), "span_b": int(span_b), "chikou": int(chikou), + "cloud_pos": pos, + } + + +def calc_indicators(ohlcv: List[dict]) -> dict: + if len(ohlcv) < 5: + return {} + closes = [float(d["close"]) for d in ohlcv] + highs = [float(d["high"]) for d in ohlcv] + lows = [float(d["low"]) for d in ohlcv] + volumes = [float(d["volume"]) for d in ohlcv] + + bb_upper, bb_mid, bb_lower, pct_b = _bollinger(closes, 20) + stoch_k, stoch_d = _stochastic(highs, lows, closes, 14) + macd, macd_signal, macd_hist = _macd(closes) + atr14 = _atr(highs, lows, closes, 14) + obv_val, obv_trend = _obv(closes, volumes) + vwap_val = _vwap(highs, lows, closes, volumes, 20) + ichi = _ichimoku(highs, lows, closes) + + return { + "price": int(closes[-1]), + "ma5": round(_ma(closes, 5)), + "ma20": round(_ma(closes, 20)), + "ma60": round(_ma(closes, 60)), + "ma120": round(_ma(closes, 120)), + "rsi": _rsi(closes, 14), + "macd": macd, + "macd_signal": macd_signal, + "macd_hist": macd_hist, + "bb_upper": bb_upper, + "bb_mid": bb_mid, + "bb_lower": bb_lower, + "pct_b": pct_b, + "stoch_k": stoch_k, + "stoch_d": stoch_d, + "vol_ratio": _vol_ratio(volumes, 20), + "atr14": atr14, + "high_52w": int(max(highs[-min(len(highs), 252):])), + "low_52w": int(min(lows[-min(len(lows), 252):])), + "obv": obv_val, + "obv_trend": obv_trend, + "vwap20": vwap_val, + "ichimoku": ichi, + } + +def calc_tech_score(ind: dict) -> tuple: + """기술적 점수 (-100~100)와 근거 신호 목록 반환""" + if not ind: + return 0.0, [] + + price = ind["price"] + score = 0.0 + signals: List[str] = [] + + # ── 이동평균 (±40) ────────────────────────── + if ind["ma5"] > ind["ma20"]: + score += 10; signals.append("MA5>MA20 단기상승") + else: + score -= 10; signals.append("MA5 ind["ma60"]: + score += 8; signals.append("MA20>MA60 중기상승") + else: + score -= 8 + + if ind["ma60"] > ind["ma120"]: + score += 7 + else: + score -= 7 + + if price > ind["ma20"]: + score += 8; signals.append("현재가 MA20 위") + elif price < ind["ma60"]: + score -= 8; signals.append("현재가 MA60 아래") + + # 정배열/역배열 + if ind["ma5"] > ind["ma20"] > ind["ma60"] > ind["ma120"]: + score += 7; signals.append("정배열") + elif ind["ma5"] < ind["ma20"] < ind["ma60"] < ind["ma120"]: + score -= 7; signals.append("역배열") + + # ── RSI (±25) ─────────────────────────────── + rsi = ind["rsi"] + if rsi <= 30: + score += 25; signals.append(f"RSI 과매도({rsi:.0f})") + elif rsi <= 40: + score += 15; signals.append(f"RSI 저점({rsi:.0f})") + elif rsi <= 60: + score += 5 + elif rsi <= 70: + score -= 5 + else: + score -= 20; signals.append(f"RSI 과매수({rsi:.0f})") + + # ── MACD (±20) ────────────────────────────── + if ind["macd_hist"] > 0 and ind["macd"] > ind["macd_signal"]: + score += 20; signals.append("MACD 골든크로스") + elif ind["macd_hist"] > 0: + score += 8 + elif ind["macd_hist"] < 0 and ind["macd"] < ind["macd_signal"]: + score -= 20; signals.append("MACD 데드크로스") + else: + score -= 5 + + # ── 볼린저밴드 (±15) ──────────────────────── + pb = ind["pct_b"] + if pb < 0.1: + score += 15; signals.append("볼밴 하단(과매도)") + elif pb < 0.3: + score += 8 + elif pb > 0.9: + score -= 15; signals.append("볼밴 상단(과매수)") + elif pb > 0.7: + score -= 5 + + # ── 스토캐스틱 (±10) ──────────────────────── + sk, sd = ind["stoch_k"], ind["stoch_d"] + if sk < 20 and sk > sd: + score += 10; signals.append("스토캐스틱 바닥반등") + elif sk > 80 and sk < sd: + score -= 10; signals.append("스토캐스틱 고점하락") + + # ── 거래량 보너스 (±5) ────────────────────── + if ind["vol_ratio"] > 1.5: + if score > 0: + score += 5; signals.append("거래량 급증(매수세)") + else: + score -= 5; signals.append("거래량 급증(매도세)") + + return round(max(-100.0, min(100.0, score)), 1), signals + +def calc_price_targets(price: int, ind: dict, sig: str) -> dict: + """매수/매도 목표가(T1/T2/T3) + 손절가 계산 (10원 단위 반올림)""" + if not ind or price <= 0: + return {} + + def r10(p): return int(round(p / 10) * 10) + + h52 = ind.get("high_52w", price * 1.3) + l52 = ind.get("low_52w", price * 0.7) + bb_up = ind.get("bb_upper", price * 1.05) + bb_dn = ind.get("bb_lower", price * 0.95) + ma20 = ind.get("ma20", price) + ma60 = ind.get("ma60", price) + + if sig == "매수": + # 진입: 현재가 기준 -2% (기술지표 확인 후 매수) + entry = r10(price * 0.98) + # T1: +7% (단기), T2: +14% (중기), T3: min(+22%, 52주고가 -3%) + t1 = r10(price * 1.07) + t2 = r10(price * 1.14) + t3 = r10(min(price * 1.22, h52 * 0.97)) + t3 = t3 if t3 > t2 else r10(price * 1.22) + # 손절: max(-8%, MA60 -5%) — 최대 -10% 이내 제한 + raw_stop = max(price * 0.92, ma60 * 0.95) + stop = r10(max(raw_stop, price * 0.90)) # 최소 -10% + er1 = round((t1 - price) / price * 100, 1) + sl_r = round(abs(stop - price) / price * 100, 1) + # M3: ATR 기반 trailing stop (현재가 기준 2 ATR 아래) + atr = ind.get("atr14", 0) + atr_trailing = r10(price - 2 * atr) if atr > 0 else stop + return { + "entry_price": entry, + "t1": t1, "t1_pct": er1, "t1_sell_pct": 50, + "t2": t2, "t2_pct": round((t2 - price) / price * 100, 1), "t2_sell_pct": 30, + "t3": t3, "t3_pct": round((t3 - price) / price * 100, 1), "t3_sell_pct": 20, + "stop_loss": stop, "stop_pct": -sl_r, + "atr14": atr, + "trailing_stop": atr_trailing, + "risk_reward": round(er1 / sl_r, 2) if sl_r > 0 else 0, + "exit_strategy": "T1 50% + T2 30% + T3 20% 분할매도, 손절 또는 trailing(ATR×2) 도달시 전량", + } + else: # 매도 / 관망(음수) + entry = r10(price * 1.02) + t1 = r10(price * 0.93) + t2 = r10(price * 0.86) + t3 = r10(max(price * 0.78, l52 * 1.03)) + t3 = t3 if t3 < t2 else r10(price * 0.78) + raw_stop = min(price * 1.08, ma20 * 1.05) + stop = r10(min(raw_stop, price * 1.10)) + er1 = round((price - t1) / price * 100, 1) + sl_r = round(abs(stop - price) / price * 100, 1) + return { + "entry_price": entry, + "t1": t1, "t1_pct": -er1, + "t2": t2, "t2_pct": -round((price - t2) / price * 100, 1), + "t3": t3, "t3_pct": -round((price - t3) / price * 100, 1), + "stop_loss": stop, "stop_pct": sl_r, + "risk_reward": round(er1 / sl_r, 2) if sl_r > 0 else 0, + } + +# ── OHLCV 수집 (네이버 차트 → yfinance 백업) ───────────── + +async def get_ohlcv_naver_chart(client: httpx.AsyncClient, code: str, count: int = 120) -> List[dict]: + """네이버 차트 API (fchart)""" + try: + r = await client.get( + f"https://fchart.stock.naver.com/sise.nhn?symbol={code}&timeframe=day&count={count}&requestType=0", + headers=HEADERS, timeout=10) + items = re.findall(r'data="([^"]+)"', r.text) + data = [] + for item in items: + p = item.split("|") + if len(p) >= 6 and all(x.strip() for x in p[:5]): + try: + data.append({ + "date": p[0], "open": int(p[1]), "high": int(p[2]), + "low": int(p[3]), "close": int(p[4]), + "volume": int(p[5]) if p[5].strip() else 0, + }) + except ValueError: + pass + return data + except Exception: + return [] + +async def get_ohlcv_naver_sise(client: httpx.AsyncClient, code: str, pages: int = 7) -> List[dict]: + """네이버 일별시세 페이지 (차트 API 실패 시 2차)""" + data = [] + try: + for page in range(1, pages + 1): + r = await client.get( + f"https://finance.naver.com/item/sise_day.naver?code={code}&page={page}", + headers=HEADERS, timeout=12) + r.encoding = "euc-kr" + # 날짜+종가+전일비+시가+고가+저가+거래량 패턴 + rows = re.findall( + r'(\d{4}\.\d{2}\.\d{2})[^<]*.*?' + r']*>([\d,]+).*?' # 종가 + r'(?:.*?){3}' + r']*>([\d,]+).*?' # 시가 + r']*>([\d,]+).*?' # 고가 + r']*>([\d,]+).*?' # 저가 + r']*>([\d,]+)', # 거래량 + r.text, re.DOTALL) + if not rows: + # 단순 종가만 추출 (더 넓은 패턴) + simple = re.findall( + r'class="tah p10 gray03">(\d{4}\.\d{2}\.\d{2})<.*?' + r'class="tah p11">([\d,]+)<', + r.text, re.DOTALL) + for date_str, close_str in simple: + close = int(close_str.replace(",", "")) + if close > 0: + data.append({"date": date_str.replace(".", ""), + "open": close, "high": close, + "low": close, "close": close, "volume": 0}) + else: + for m in rows: + date_str = m[0].replace(".", "") + close = int(m[1].replace(",", "")) + open_ = int(m[2].replace(",", "")) if m[2] else close + high = int(m[3].replace(",", "")) if m[3] else close + low = int(m[4].replace(",", "")) if m[4] else close + vol = int(m[5].replace(",", "")) if m[5] else 0 + if close > 0: + data.append({"date": date_str, "open": open_, + "high": high, "low": low, + "close": close, "volume": vol}) + if len(data) >= 120: + break + await asyncio.sleep(0.15) + except Exception as e: + logger.warning("ohlcv.sise.err", code=code, error=str(e)) + return data[:120] + +async def get_ohlcv_yfinance(code: str) -> List[dict]: + """yfinance 최종 백업""" + try: + import yfinance as yf + loop = asyncio.get_event_loop() + def _fetch(): + t = yf.Ticker(f"{code}.KS") + return t.history(period="1y") + h = await loop.run_in_executor(None, _fetch) + if h.empty: + return [] + return [{"date": idx.strftime("%Y%m%d"), + "open": int(row["Open"] or 0), "high": int(row["High"] or 0), + "low": int(row["Low"] or 0), "close": int(row["Close"] or 0), + "volume": int(row["Volume"] or 0)} + for idx, row in h.iterrows() if row["Close"] and row["High"]] + except Exception as e: + logger.warning("ohlcv.yfinance.err", code=code, error=str(e)) + return [] + +async def get_ohlcv(client: httpx.AsyncClient, code: str, count: int = 120) -> List[dict]: + data = await get_ohlcv_naver_chart(client, code, count) + if len(data) < 20: + data = await get_ohlcv_naver_sise(client, code) + if len(data) < 20: + logger.info("ohlcv.fallback.yfinance", code=code) + await asyncio.sleep(0.5) # rate limit 방지 + data = await get_ohlcv_yfinance(code) + return data + +# ── vLLM AI 판단문 생성 ─────────────────────────────────── + +async def generate_ai_opinion(client: httpx.AsyncClient, code: str, name: str, + ind: dict, tech_score: float, signals: List[str], + targets: dict, news_score: float = 0) -> str: + """vLLM으로 문장형 투자 판단 생성""" + sig = "매수" if tech_score >= 30 else ("매도" if tech_score <= -30 else "관망") + price = ind.get("price", 0) + rsi = ind.get("rsi", 50) + h52 = ind.get("high_52w", price) + l52 = ind.get("low_52w", price) + pos52 = int((price - l52) / (h52 - l52) * 100) if h52 != l52 else 50 + + prompt = f"""다음 주식 데이터를 바탕으로 투자자에게 명확한 매매 판단을 3~5문장으로 설명하세요. +한국어로, 구체적인 가격과 수치를 포함해서 작성하세요. + +종목: {name}({code}) +현재가: {price:,}원 +기술점수: {tech_score}점 / 신호: {sig} +이동평균: MA5={ind.get('ma5',0):,} MA20={ind.get('ma20',0):,} MA60={ind.get('ma60',0):,} +RSI: {rsi} / MACD히스토그램: {'양수(골든)' if ind.get('macd_hist',0)>0 else '음수(데드)'} +볼린저%B: {ind.get('pct_b',0.5)*100:.0f}% +52주위치: 하단에서 {pos52}% +뉴스감성점수: {news_score:.0f} +기술신호: {', '.join(signals[:4])} +{f"1차목표가: {targets.get('t1',0):,}원 / 손절가: {targets.get('stop_loss',0):,}원" if targets else ""} + +투자 판단 (3~5문장):""" + + try: + r = await client.post(f"{OLLAMA_URL}/v1/chat/completions", json={ + "model": "exaone3.5:7.8b", + "messages": [ + {"role": "system", "content": "당신은 한국 주식 전문 애널리스트입니다. 기술적 분석 데이터를 바탕으로 명확하고 실용적인 투자 의견을 제시합니다."}, + {"role": "user", "content": prompt} + ], + "max_tokens": 300, "temperature": 0.2 + }, timeout=60) + return r.json()["choices"][0]["message"]["content"].strip() + except Exception as e: + logger.warning("ai_opinion.err", code=code, error=str(e)) + return "" + +# ── 포지션 손익 분석 ────────────────────────────────────── + +class PositionRequest(BaseModel): + code: str + name: str = "" + buy_price: int + qty: int + +def analyze_position(price: int, buy_price: int, qty: int, + ind: dict, tech_score: float) -> dict: + """보유 포지션 기반 맞춤 전략 계산""" + pnl = (price - buy_price) * qty + pnl_pct = (price - buy_price) / buy_price * 100 + total_buy = buy_price * qty + h52 = ind.get("high_52w", price * 1.3) + l52 = ind.get("low_52w", price * 0.7) + ma20 = ind.get("ma20", price) + ma60 = ind.get("ma60", price) + bbu = ind.get("bb_upper", price * 1.05) + + def r10(p): return int(round(p / 10) * 10) + + # 손절선: 매입가 -8% 또는 MA60 -3% 중 높은 것 + stop = max(r10(buy_price * 0.92), r10(ma60 * 0.97)) + + # 목표가 + t1 = r10(max(buy_price * 1.08, bbu * 0.97)) # 본전+8% 또는 볼밴 상단 + t2 = r10(max((price + h52) / 2, buy_price * 1.15)) + t3 = r10(max(h52 * 0.97, buy_price * 1.25)) + + # 추가매수 구간 (물타기) - 현재가 -5%, -10% + avg_down1_price = r10(price * 0.95) + avg_down1_qty = max(1, qty // 3) + avg_down1_avg = (total_buy + avg_down1_price * avg_down1_qty) / (qty + avg_down1_qty) + + avg_down2_price = r10(price * 0.90) + avg_down2_qty = max(1, qty // 2) + avg_down2_avg = (total_buy + avg_down2_price * avg_down2_qty) / (qty + avg_down2_qty) + + return { + "pnl": pnl, + "pnl_pct": round(pnl_pct, 2), + "total_buy": total_buy, + "current_value": price * qty, + "stop_loss": stop, + "stop_pnl": (stop - buy_price) * qty, + "t1": t1, "t1_pnl": (t1 - buy_price) * qty, + "t2": t2, "t2_pnl": (t2 - buy_price) * qty, + "t3": t3, "t3_pnl": (t3 - buy_price) * qty, + "avg_down": [ + {"price": avg_down1_price, "add_qty": avg_down1_qty, + "new_avg": round(avg_down1_avg), "add_cost": avg_down1_price * avg_down1_qty}, + {"price": avg_down2_price, "add_qty": avg_down2_qty, + "new_avg": round(avg_down2_avg), "add_cost": avg_down2_price * avg_down2_qty}, + ], + "action": ( + "손절 고려" if price <= stop else + "추가매수 검토" if pnl_pct < -5 and tech_score >= 0 else + "홀드" if -5 <= pnl_pct < 5 else + "1차 익절 고려" if pnl_pct >= 10 else "홀드" + ), + } + +# ── 단일 종목 분석 ──────────────────────────────────────── + +EXCLUDE_KEYWORDS = ( + "기업인수목적", "선박투자회사", "부동산투자회사", "특별자산", "인프라투자", + "사모투자", "맥쿼리", "리츠", "REITs", +) + +async def analyze_stock(client: httpx.AsyncClient, code: str, name: str = "", + with_ai: bool = False, news_score: float = 0) -> Optional[dict]: + # 이름이 없으면 DB에서 조회 + if not name and pg_pool: + try: + async with pg_pool.acquire() as conn: + name = await conn.fetchval( + "SELECT corp_name FROM dart_corps WHERE stock_code=$1", code) or "" + except: pass + + # SPAC·REITs·선박펀드 등 제외 + if any(kw in name for kw in EXCLUDE_KEYWORDS): + return None + + ohlcv = await get_ohlcv(client, code, 120) + if len(ohlcv) < 20: + return None + + ind = calc_indicators(ohlcv) + if not ind: + return None + + # 주가 500원 미만 penny stock 제외 + if ind.get("price", 0) < 500: + return None + + tech_score, signals = calc_tech_score(ind) + sig = "매수" if tech_score >= 30 else ("매도" if tech_score <= -30 else "관망") + # 관망도 목표가 계산 (기술점수 양수면 매수 기준, 음수면 매도 기준) + tgt_sig = "매수" if tech_score >= 0 else "매도" + targets = calc_price_targets(ind["price"], ind, tgt_sig) + + ai_opinion = "" + if with_ai and sig != "관망": + ai_opinion = await generate_ai_opinion( + client, code, name, ind, tech_score, signals, targets, news_score) + + result = { + "code": code, "name": name, + "tech_score": tech_score, "signal": sig, + "signals": signals, "indicators": ind, "targets": targets, + "ai_opinion": ai_opinion, + "analyzed_at": datetime.now().isoformat(), + } + + if redis_cl: + try: + await redis_cl.set(f"ta:{code}", json.dumps(result, ensure_ascii=False), ex=1800) + except: pass + + if pg_pool: + try: + async with pg_pool.acquire() as conn: + await conn.execute(""" + INSERT INTO stock_technical ( + stock_code, stock_name, price, + ma5, ma20, ma60, ma120, + rsi, macd, macd_signal, macd_hist, + bb_upper, bb_mid, bb_lower, pct_b, + stoch_k, stoch_d, vol_ratio, + tech_score, signal, signals, targets, analyzed_at + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23) + ON CONFLICT (stock_code) DO UPDATE SET + stock_name=$2, price=$3, + ma5=$4, ma20=$5, ma60=$6, ma120=$7, + rsi=$8, macd=$9, macd_signal=$10, macd_hist=$11, + bb_upper=$12, bb_mid=$13, bb_lower=$14, pct_b=$15, + stoch_k=$16, stoch_d=$17, vol_ratio=$18, + tech_score=$19, signal=$20, signals=$21, targets=$22, analyzed_at=$23 + """, + code, name, ind["price"], + ind["ma5"], ind["ma20"], ind["ma60"], ind["ma120"], + ind["rsi"], ind["macd"], ind["macd_signal"], ind["macd_hist"], + ind["bb_upper"], ind["bb_mid"], ind["bb_lower"], ind["pct_b"], + ind["stoch_k"], ind["stoch_d"], ind["vol_ratio"], + tech_score, sig, + json.dumps(signals, ensure_ascii=False), + json.dumps(targets, ensure_ascii=False), + datetime.now()) + except Exception as e: + logger.warning("ta.db.err", code=code, error=str(e)) + + return result + +# ── DB 초기화 ───────────────────────────────────────────── + +async def init_db(): + async with pg_pool.acquire() as conn: + await conn.execute(""" + CREATE TABLE IF NOT EXISTS stock_technical ( + id SERIAL PRIMARY KEY, + stock_code VARCHAR(10) UNIQUE NOT NULL, + stock_name VARCHAR(100) DEFAULT '', + price INTEGER DEFAULT 0, + ma5 FLOAT DEFAULT 0, + ma20 FLOAT DEFAULT 0, + ma60 FLOAT DEFAULT 0, + ma120 FLOAT DEFAULT 0, + rsi FLOAT DEFAULT 50, + macd FLOAT DEFAULT 0, + macd_signal FLOAT DEFAULT 0, + macd_hist FLOAT DEFAULT 0, + bb_upper FLOAT DEFAULT 0, + bb_mid FLOAT DEFAULT 0, + bb_lower FLOAT DEFAULT 0, + pct_b FLOAT DEFAULT 0.5, + stoch_k FLOAT DEFAULT 50, + stoch_d FLOAT DEFAULT 50, + vol_ratio FLOAT DEFAULT 1, + tech_score FLOAT DEFAULT 0, + signal VARCHAR(10) DEFAULT '관망', + signals JSONB DEFAULT '[]'::jsonb, + targets JSONB DEFAULT '{}'::jsonb, + analyzed_at TIMESTAMP DEFAULT NOW() + ) + """) + await conn.execute("CREATE INDEX IF NOT EXISTS idx_ta_score ON stock_technical(tech_score DESC)") + await conn.execute("CREATE INDEX IF NOT EXISTS idx_ta_signal ON stock_technical(signal)") + logger.info("ta.db.initialized") + +# ── 전체 분석 작업 ──────────────────────────────────────── + +async def job_analyze(): + logger.info("ta.job.start") + async with httpx.AsyncClient() as client: + codes: List[tuple] = [] + + if pg_pool: + try: + rows = await pg_pool.fetch("SELECT stock_code, corp_name FROM dart_corps LIMIT 500") + codes = [(r["stock_code"], r["corp_name"] or "") for r in rows if r["stock_code"]] + except: pass + + if not codes: + for sosok in [0, 1]: + for page in range(1, 30): + try: + r = await client.get( + f"https://finance.naver.com/sise/sise_market_sum.naver?sosok={sosok}&page={page}", + headers=HEADERS, timeout=15) + r.encoding = "euc-kr" + found = re.findall(r'main\.naver\?code=(\d{6})[^>]*>([^<]+)', r.text) + if not found: break + codes.extend([(c.strip(), n.strip()) for c, n in found]) + await asyncio.sleep(0.2) + except: break + if len(codes) >= 500: break + + ok = 0 + for code, name in codes[:500]: + if not code or len(code) != 6: continue + try: + result = await analyze_stock(client, code, name) + if result: ok += 1 + except Exception as e: + stats.errors += 1 + logger.warning("ta.analyze.err", code=code, error=str(e)) + await asyncio.sleep(0.4) + + stats.analyzed += ok + stats.last_run = datetime.now().isoformat() + logger.info("ta.job.done", analyzed=ok) + +# ── FastAPI ──────────────────────────────────────────────── + +app = FastAPI(title="기술적 분석 엔진") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +@app.on_event("startup") +async def startup(): + global pg_pool, redis_cl + pg_pool = await asyncpg.create_pool( + host=PG_HOST, port=PG_PORT, database=PG_DB, + user=PG_USER, password=PG_PASS, min_size=2, max_size=5) + redis_cl = aioredis.Redis( + host=REDIS_HOST, port=6379, password=REDIS_PASSWORD, db=5, decode_responses=True) + await init_db() + scheduler.add_job(job_analyze, "cron", day_of_week="mon-fri", + hour="9-16", minute="*/30", id="ta_30m", replace_existing=True) + scheduler.add_job(job_analyze, "cron", day_of_week="mon-fri", + hour=16, minute=15, id="ta_close", replace_existing=True) + scheduler.start() + logger.info("ta-engine.started") + +@app.on_event("shutdown") +async def shutdown(): + scheduler.shutdown() + if pg_pool: await pg_pool.close() + if redis_cl: await redis_cl.aclose() + +@app.get("/health") +async def health(): + return {"status": "ok", "analyzed": stats.analyzed, + "errors": stats.errors, "last_run": stats.last_run} + +@app.get("/technical/{code}") +async def technical(code: str): + if redis_cl: + cached = await redis_cl.get(f"ta:{code}") + if cached: + return JSONResponse(content=json.loads(cached)) + if pg_pool: + async with pg_pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM stock_technical WHERE stock_code=$1", code) + if row: + d = dict(row) + d["analyzed_at"] = str(d["analyzed_at"]) + for k in ("signals", "targets"): + if isinstance(d[k], str): + d[k] = json.loads(d[k]) + return JSONResponse(content=d) + # 실시간 분석 + async with httpx.AsyncClient() as client: + result = await analyze_stock(client, code) + if result: + return JSONResponse(content=result) + return JSONResponse(content={"error": "not found"}, status_code=404) + +@app.get("/ranking") +async def ranking(limit: int = Query(default=30), signal: str = Query(default="")): + async with pg_pool.acquire() as conn: + if signal: + rows = await conn.fetch( + "SELECT * FROM stock_technical WHERE signal=$1 ORDER BY tech_score DESC LIMIT $2", + signal, limit) + else: + rows = await conn.fetch( + "SELECT * FROM stock_technical ORDER BY tech_score DESC LIMIT $1", limit) + result = [] + for row in rows: + d = dict(row) + d["analyzed_at"] = str(d["analyzed_at"]) + for k in ("signals", "targets"): + if isinstance(d[k], str): + d[k] = json.loads(d[k]) + result.append(d) + return result + +@app.get("/buy-candidates") +async def buy_candidates(limit: int = Query(default=20)): + """기술적 매수 후보 (점수 30 이상) + 펀더멘탈 점수 합산""" + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT t.*, + s.news_score, s.dart_score, s.recommendation AS fundamental_rec, + s.total_score AS fundamental_total + FROM stock_technical t + LEFT JOIN stock_scores s + ON t.stock_code = s.stock_code + AND s.score_date = (SELECT MAX(score_date) FROM stock_scores) + WHERE t.signal = '매수' AND t.tech_score >= 30 + ORDER BY (t.tech_score + COALESCE(s.total_score, 0)) DESC + LIMIT $1 + """, limit) + result = [] + for row in rows: + d = dict(row) + d["analyzed_at"] = str(d["analyzed_at"]) + for k in ("signals", "targets"): + if isinstance(d.get(k), str): + d[k] = json.loads(d[k]) + result.append(d) + return result + +@app.post("/analyze/all") +async def analyze_all(): + asyncio.create_task(job_analyze()) + return {"status": "started"} + +@app.post("/analyze/{code}") +async def analyze_single(code: str, ai: bool = False): + async with httpx.AsyncClient() as client: + result = await analyze_stock(client, code, with_ai=ai) + if result: + return JSONResponse(content=result) + return JSONResponse(content={"error": "analysis failed"}, status_code=500) + +# ── 보유 포지션 맞춤 분석 ──────────────────────────────── + +@app.post("/position") +async def position_analysis(req: PositionRequest, ai: bool = False): + """보유 종목 매입가/수량 기반 맞춤 손익 + 전략 분석""" + code = req.code + + # 캐시 확인 + result = None + if redis_cl: + try: + cached = await redis_cl.get(f"ta:{code}") + if cached: + result = json.loads(cached) + except: pass + + if not result: + async with httpx.AsyncClient() as client: + result = await analyze_stock(client, code, req.name, with_ai=False) + + if not result: + return JSONResponse(content={"error": "종목 분석 실패"}, status_code=500) + + ind = result.get("indicators", {}) + tech_score = result.get("tech_score", 0) + price = ind.get("price", 0) + + pos = analyze_position(price, req.buy_price, req.qty, ind, tech_score) + + # AI 판단문 (요청 시) + ai_opinion = result.get("ai_opinion", "") + if ai and not ai_opinion: + async with httpx.AsyncClient() as client: + ai_opinion = await generate_ai_opinion( + client, code, req.name or result.get("name", code), + ind, tech_score, result.get("signals", []), + result.get("targets", {})) + + return { + "code": code, + "name": req.name or result.get("name", code), + "buy_price": req.buy_price, + "qty": req.qty, + "current_price": price, + "tech_score": tech_score, + "signal": result.get("signal"), + "signals": result.get("signals", []), + "indicators": ind, + "position": pos, + "targets": result.get("targets", {}), + "ai_opinion": ai_opinion, + "analyzed_at": result.get("analyzed_at"), + } + +# ── 종목 전체 리포트 (AI 판단문 포함) ──────────────────── + +@app.get("/report/{code}") +async def full_report(code: str): + """기술적 분석 + AI 판단문 + 뉴스감성 통합 리포트""" + news_score = 0.0 + if pg_pool: + try: + async with pg_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT news_score FROM stock_scores WHERE stock_code=$1 " + "ORDER BY score_date DESC LIMIT 1", code) + if row: + news_score = float(row["news_score"] or 0) + except: pass + + async with httpx.AsyncClient() as client: + result = await analyze_stock(client, code, with_ai=True, news_score=news_score) + + if not result: + return JSONResponse(content={"error": "분석 실패"}, status_code=500) + + # DB에서 추가 정보 + extra = {} + if pg_pool: + try: + async with pg_pool.acquire() as conn: + score_row = await conn.fetchrow( + "SELECT * FROM stock_scores WHERE stock_code=$1 " + "ORDER BY score_date DESC LIMIT 1", code) + news_rows = await conn.fetch( + "SELECT title, sentiment, intensity, reason " + "FROM news_analysis WHERE primary_stock=$1 " + "ORDER BY analyzed_at DESC LIMIT 5", code) + if score_row: + extra["score"] = dict(score_row) + extra["score"]["score_date"] = str(extra["score"]["score_date"]) + extra["recent_news"] = [dict(r) for r in news_rows] + except: pass + + return {**result, **extra, "news_score": news_score} diff --git a/ta-engine/requirements.txt b/ta-engine/requirements.txt new file mode 100644 index 0000000..97c5271 --- /dev/null +++ b/ta-engine/requirements.txt @@ -0,0 +1,10 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +httpx==0.27.0 +redis==5.0.4 +asyncpg==0.29.0 +apscheduler==3.10.4 +orjson==3.10.3 +structlog==24.2.0 +yfinance==0.2.54 +pydantic==2.7.1 diff --git a/telegram-bot/Dockerfile b/telegram-bot/Dockerfile new file mode 100644 index 0000000..fd157f5 --- /dev/null +++ b/telegram-bot/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +CMD ["python", "-u", "main.py"] diff --git a/telegram-bot/main.py b/telegram-bot/main.py new file mode 100644 index 0000000..9fe6623 --- /dev/null +++ b/telegram-bot/main.py @@ -0,0 +1,499 @@ +""" +Trading AI Telegram Bot (양방향 대화) + +명령어: + /start, /help — 안내 + /추천 또는 /buy — 오늘 강력매수 top 5 + /매도 또는 /sell — 오늘 강력매도 top 3 + /종목 005930 또는 /stock 005930 — 특정 종목 상세 + /시장 또는 /market — 시장 상황 요약 + +자유 텍스트 → EXAONE 3.5가 시스템 DB 데이터 + 사용자 질문 종합해 답변 +""" +import os +import re +import asyncio +import json +import logging +from datetime import datetime +from typing import Optional, List + +import asyncpg +import httpx +import structlog +from telegram import Update, BotCommand +from telegram.constants import ParseMode +from telegram.ext import (Application, CommandHandler, MessageHandler, + filters, ContextTypes) + +logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = structlog.get_logger() + +PG = { + "host": os.getenv("POSTGRES_HOST", "postgres"), + "port": int(os.getenv("POSTGRES_PORT", 5432)), + "database": os.getenv("POSTGRES_DB", "trading_ai"), + "user": os.getenv("POSTGRES_USER", "kyu"), + "password": os.getenv("POSTGRES_PASSWORD", ""), +} +TG_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN", "") +ALLOWED_CHAT_ID = os.getenv("TELEGRAM_CHAT_ID", "") # 허용된 사용자만 +OLLAMA_URL = os.getenv("OLLAMA_URL", "http://ollama:11434") +EXAONE_MODEL = os.getenv("EXAONE_MODEL", "exaone3.5:7.8b") +SCORE_API = os.getenv("SCORE_API_URL", "http://score-engine:8686") + +pg_pool: Optional[asyncpg.Pool] = None + + +# ───────────────────────────────────────────────────────────── +# 권한 체크 +# ───────────────────────────────────────────────────────────── +def is_allowed(update: Update) -> bool: + if not ALLOWED_CHAT_ID: + return True + return str(update.effective_chat.id) == ALLOWED_CHAT_ID + + +# ───────────────────────────────────────────────────────────── +# DB 조회 헬퍼 +# ───────────────────────────────────────────────────────────── +async def db_top_buys(limit: int = 5) -> list: + async with pg_pool.acquire() as conn: + return await conn.fetch(""" + SELECT s.stock_code, s.stock_name, s.total_score, s.recommendation, + s.us_overnight_adj, s.us_pair_top, + s.insider_signal, s.consensus_signal, s.macro_signal, + s.inst_flow_signal, s.valuation_pct, + s.top_reasons + FROM stock_scores s + WHERE s.score_date=(SELECT MAX(score_date) FROM stock_scores) + AND s.recommendation IN ('강력매수','매수관심') + ORDER BY s.total_score DESC LIMIT $1 + """, limit) + + +async def db_top_sells(limit: int = 3) -> list: + async with pg_pool.acquire() as conn: + return await conn.fetch(""" + SELECT stock_code, stock_name, total_score, recommendation, top_reasons + FROM stock_scores + WHERE score_date=(SELECT MAX(score_date) FROM stock_scores) + AND recommendation IN ('강력매도','매도관심') + ORDER BY total_score ASC LIMIT $1 + """, limit) + + +async def db_stock_detail(code: str) -> Optional[dict]: + async with pg_pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT s.*, + d.corp_name, d.sector_name, + f.roe, f.operating_margin, f.debt_ratio, f.revenue_growth, + f.fcf_ratio + FROM stock_scores s + JOIN dart_corps d ON d.stock_code=s.stock_code + LEFT JOIN dart_financials f ON f.stock_code=s.stock_code + AND f.reprt_code='11011' + AND f.bsns_year=(SELECT MAX(bsns_year) FROM dart_financials f2 + WHERE f2.stock_code=s.stock_code AND f2.reprt_code='11011') + WHERE s.stock_code=$1 + AND s.score_date=(SELECT MAX(score_date) FROM stock_scores + WHERE stock_code=$1) + """, code) + return dict(row) if row else None + + +async def db_market_regime() -> Optional[dict]: + async with pg_pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT regime, regime_adj, dt FROM market_regime ORDER BY dt DESC LIMIT 1") + macro = await conn.fetch(""" + SELECT DISTINCT ON (indicator) indicator, trade_date, value + FROM macro_daily ORDER BY indicator, trade_date DESC + """) + return { + "regime": dict(row) if row else None, + "macro": {r["indicator"]: float(r["value"]) for r in macro} + } + + +async def find_stock_code_by_name(name: str) -> Optional[str]: + """이름으로 종목코드 검색""" + async with pg_pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT stock_code FROM dart_corps + WHERE is_active=true AND corp_name ILIKE $1 + ORDER BY CASE WHEN corp_name=$2 THEN 0 ELSE 1 END + LIMIT 1 + """, f"%{name}%", name) + return row["stock_code"] if row else None + + +# ───────────────────────────────────────────────────────────── +# EXAONE 호출 +# ───────────────────────────────────────────────────────────── +SYSTEM_PROMPT = """당신은 워렌 버핏 스타일의 한국 주식 가치투자 분석가입니다. +사용자 질문에 대해 제공된 시스템 데이터를 우선 참고하여 답변하세요. + +원칙: +- 데이터에 근거한 객관적 답변 +- 짧고 명확하게 (3~5문장) +- ROE/부채비율/영업이익률 중심의 가치투자 관점 +- 투자는 본인 책임이라는 점 마지막에 짧게 언급 +- 데이터에 없는 내용은 추측하지 말고 "모르겠습니다"라고 말할 것 +""" + + +async def ask_exaone(user_msg: str, context: str = "") -> str: + prompt = f"{SYSTEM_PROMPT}\n\n=== 시스템 데이터 ===\n{context}\n\n=== 사용자 질문 ===\n{user_msg}" + try: + async with httpx.AsyncClient(timeout=120) as cl: + r = await cl.post(f"{OLLAMA_URL}/api/generate", json={ + "model": EXAONE_MODEL, "prompt": prompt, "stream": False, + "options": {"temperature": 0.3, "num_predict": 400} + }) + if r.status_code != 200: + return f"⚠️ LLM 호출 실패 (status {r.status_code})" + j = r.json() + return j.get("response", "").strip() or "(빈 응답)" + except Exception as e: + return f"⚠️ LLM 오류: {e}" + + +# ───────────────────────────────────────────────────────────── +# 포매팅 헬퍼 +# ───────────────────────────────────────────────────────────── +def fmt_pct(v) -> str: + try: return f"{float(v):+.1f}" + except: return "-" + + +def fmt_buys(rows: list) -> str: + if not rows: + return "📭 오늘 매수 추천 없음" + lines = ["🟢 오늘 매수 추천"] + for i, r in enumerate(rows, 1): + emoji = "🔥" if r["recommendation"] == "강력매수" else "✨" + line = f"{emoji} {i}. {r['stock_name']}({r['stock_code']}) {r['total_score']:.1f}점" + lines.append(line) + sigs = [] + if r["us_overnight_adj"] and r["us_overnight_adj"] != 0: + sigs.append(f"US{fmt_pct(r['us_overnight_adj'])}") + if r["insider_signal"] and r["insider_signal"] != 0: + sigs.append(f"내부자{fmt_pct(r['insider_signal'])}") + if r["consensus_signal"] and r["consensus_signal"] != 0: + sigs.append(f"컨센{fmt_pct(r['consensus_signal'])}") + if r["valuation_pct"] and r["valuation_pct"] != 0: + sigs.append(f"밸류{fmt_pct(r['valuation_pct'])}") + if r["inst_flow_signal"] and r["inst_flow_signal"] != 0: + sigs.append(f"기관{fmt_pct(r['inst_flow_signal'])}") + if sigs: + lines.append(f" ▸ {' / '.join(sigs)}") + if r["top_reasons"]: + lines.append(f" ▸ {r['top_reasons'][:80]}") + return "\n".join(lines) + + +def fmt_sells(rows: list) -> str: + if not rows: + return "📭 오늘 매도 추천 없음" + lines = ["🔴 오늘 매도 신호"] + for i, r in enumerate(rows, 1): + lines.append(f"{i}. {r['stock_name']}({r['stock_code']}) {r['total_score']:.1f}점 · {r['recommendation']}") + if r["top_reasons"]: + lines.append(f" ▸ {r['top_reasons'][:80]}") + return "\n".join(lines) + + +def fmt_stock(d: dict) -> str: + if not d: + return "❓ 종목을 찾을 수 없습니다" + lines = [ + f"📊 {d['corp_name']}({d['stock_code']})", + f"종합점수 {d['total_score']:.1f} · {d['recommendation']}", + f"섹터: {d.get('sector_name') or '미분류'}", + "", + "📈 시그널", + ] + sigs = [ + ("뉴스", d.get("news_score")), + ("공시", d.get("dart_score")), + ("기술", d.get("technical_score")), + ("외국인", d.get("foreign_score")), + ("공매도", d.get("short_score")), + ("미국동조", d.get("us_overnight_adj")), + ("내부자", d.get("insider_signal")), + ("컨센", d.get("consensus_signal")), + ("매크로", d.get("macro_signal")), + ("기관 5d", d.get("inst_flow_signal")), + ("밸류", d.get("valuation_pct")), + ] + for k, v in sigs: + if v and float(v) != 0: + lines.append(f" {k}: {fmt_pct(v)}") + lines.append("") + lines.append("💰 재무") + if d.get("roe") is not None: + lines.append(f" ROE {d['roe']:.1f}% · 부채 {d.get('debt_ratio',0):.0f}% · 영업이익률 {d.get('operating_margin',0):.1f}%") + if d.get("intrinsic_value"): + lines.append(f" 내재가치 {int(d['intrinsic_value']):,}원 · 안전마진 {d.get('margin_of_safety',0):.0f}%") + if d.get("us_pair_top"): + lines.append("") + lines.append(f"🇺🇸 {d['us_pair_top']}") + if d.get("top_reasons"): + lines.append("") + lines.append(f"{d['top_reasons'][:200]}") + return "\n".join(lines) + + +# ───────────────────────────────────────────────────────────── +# 핸들러 +# ───────────────────────────────────────────────────────────── +async def cmd_start(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_allowed(update): return + msg = ( + "👋 Trading AI 봇\n\n" + "명령어:\n" + "/buy — 오늘 매수 추천\n" + "/sell — 오늘 매도 신호\n" + "/stock 005930 — 특정 종목 상세\n" + "/deep 005930 — AI 심층분석(RAG+EXAONE)\n" + "/market — 시장 상황\n" + "/help — 도움말\n\n" + "자유 텍스트로 질문해도 됩니다. 예:\n" + "\"에스엠 사도 돼?\"\n" + "\"오늘 추천 알려줘\"\n" + "\"삼성전자 어때?\"" + ) + await update.message.reply_text(msg, parse_mode=ParseMode.HTML) + + +async def cmd_help(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + await cmd_start(update, ctx) + + +async def cmd_buys(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_allowed(update): return + rows = await db_top_buys(10) + await update.message.reply_text(fmt_buys(rows), parse_mode=ParseMode.HTML) + + +async def cmd_sells(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_allowed(update): return + rows = await db_top_sells(5) + await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML) + + +async def cmd_stock(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_allowed(update): return + args = ctx.args + if not args: + await update.message.reply_text("종목코드 또는 이름을 입력하세요. 예: /종목 005930 또는 /종목 삼성전자") + return + q = " ".join(args).strip() + code = q if re.match(r"^\d{6}$", q) else await find_stock_code_by_name(q) + if not code: + await update.message.reply_text(f"❓ '{q}' 종목을 찾을 수 없습니다") + return + d = await db_stock_detail(code) + await update.message.reply_text(fmt_stock(d), parse_mode=ParseMode.HTML) + + +def fmt_deep(r: dict) -> str: + if r.get("error"): + return f"⚠️ {r.get('code')}: {r['error']}" + icon = {"강력매수": "🟢🟢", "매수": "🟢", "중립": "⚪", + "매도": "🔴", "강력매도": "🔴🔴"}.get(r.get("recommendation"), "⚪") + lines = [ + f"{icon} {r.get('name')}({r.get('code')}) — AI심층분석", + f"판단: {r.get('recommendation')} (확신도 {r.get('conviction')}/5) " + f"· 퀀트 {r.get('quant_score',0):.0f}점", + f"밸류: {r.get('valuation_view','-')} · 기간: {r.get('time_horizon','-')}", + "", + f"📝 투자논거\n{r.get('thesis','')}", + ] + if r.get("key_points"): + lines.append("\n✅ 핵심근거") + lines += [f" • {p}" for p in r["key_points"][:5]] + if r.get("risks"): + lines.append("\n⚠️ 리스크") + lines += [f" • {p}" for p in r["risks"][:4]] + if r.get("catalyst_watch"): + lines.append("\n👀 관전포인트") + lines += [f" • {p}" for p in r["catalyst_watch"][:3]] + tp, sl = r.get("target_price", 0) or 0, r.get("stop_loss", 0) or 0 + if tp or sl: + lines.append(f"\n🎯 목표가 {tp:,}원 · 손절가 {sl:,}원") + lines.append("\n※ 투자 판단·책임은 본인에게 있습니다") + return "\n".join(lines) + + +async def cmd_deep(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_allowed(update): return + args = ctx.args + if not args: + await update.message.reply_text("종목코드/이름 입력. 예: /deep 005930 또는 /deep 삼성전자") + return + q = " ".join(args).strip() + code = q if re.match(r"^\d{6}$", q) else await find_stock_code_by_name(q) + if not code: + await update.message.reply_text(f"❓ '{q}' 종목을 찾을 수 없습니다") + return + await update.message.reply_text("🧠 RAG+EXAONE 심층분석 중… (최대 1~2분)") + try: + async with httpx.AsyncClient(timeout=200) as cl: + resp = await cl.get(f"{SCORE_API}/deep-analysis/{code}", + params={"refresh": "true", "notify": "false"}) + r = resp.json() + except Exception as e: + await update.message.reply_text(f"⚠️ 분석 실패: {e}") + return + await update.message.reply_text(fmt_deep(r), parse_mode=ParseMode.HTML) + + +async def cmd_market(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_allowed(update): return + m = await db_market_regime() + regime = m.get("regime") or {} + macro = m.get("macro") or {} + lines = ["🌍 시장 상황"] + if regime: + lines.append(f" 레짐: {regime.get('regime','?')} (보정 {regime.get('regime_adj',0):+.0f})") + lines.append(f" 날짜: {regime.get('dt')}") + lines.append("") + lines.append("매크로") + if "usdkrw" in macro: lines.append(f" USD/KRW {macro['usdkrw']:.1f}원") + if "kor_10y" in macro: lines.append(f" 국고채 10년 {macro['kor_10y']:.2f}%") + if "kor_3y" in macro: lines.append(f" 국고채 3년 {macro['kor_3y']:.2f}%") + if "kospi" in macro: lines.append(f" KOSPI {macro['kospi']:,.1f}") + await update.message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) + + +# 자유 텍스트 +async def on_text(update: Update, ctx: ContextTypes.DEFAULT_TYPE): + if not is_allowed(update): return + text = (update.message.text or "").strip() + if not text: + return + + # 한글 단축 키워드 → 명령 라우팅 (빠른 응답) + if text in ("추천", "매수", "오늘추천", "buy"): + rows = await db_top_buys(10) + await update.message.reply_text(fmt_buys(rows), parse_mode=ParseMode.HTML) + return + if text in ("매도", "팔것", "sell"): + rows = await db_top_sells(5) + await update.message.reply_text(fmt_sells(rows), parse_mode=ParseMode.HTML) + return + if text in ("시장", "장", "market"): + await cmd_market(update, ctx) + return + + await update.message.chat.send_action("typing") + + # 컨텍스트 구성: 종목명 언급되면 그 종목 데이터, 아니면 시장 전반 + context_parts = [] + # 종목코드 / 종목명 감지 + code = None + code_match = re.search(r"\b(\d{6})\b", text) + if code_match: + code = code_match.group(1) + if not code: + # 종목명 추정 (2~10자 한글) + for cand in re.findall(r"[가-힣A-Z]{2,10}", text): + if cand in ("매수", "매도", "추천", "오늘", "지금", "사도", "팔아", "어때"): + continue + c = await find_stock_code_by_name(cand) + if c: + code = c + break + + if code: + d = await db_stock_detail(code) + if d: + context_parts.append(f"[종목 {d['corp_name']}({code}) 데이터]") + context_parts.append(f"종합점수 {d.get('total_score',0):.1f}점, 등급 {d.get('recommendation','-')}, 섹터 {d.get('sector_name') or '미분류'}") + context_parts.append( + f"세부 점수 — 뉴스 {d.get('news_score',0):.0f}, 기술 {d.get('technical_score',0):.0f}, " + f"공시 {d.get('dart_score',0):.0f}, 외국인 {d.get('foreign_score',0):.0f}, " + f"미국동조 {d.get('us_overnight_adj',0):.1f}, 내부자 {d.get('insider_signal',0):.1f}, " + f"컨센서스 {d.get('consensus_signal',0):.1f}, 기관5d {d.get('inst_flow_signal',0):.1f}, " + f"밸류 percentile {d.get('valuation_pct',0):.1f}") + if d.get("roe") is not None: + context_parts.append( + f"재무 — ROE {d['roe']:.1f}%, 영업이익률 {d.get('operating_margin',0):.1f}%, " + f"부채비율 {d.get('debt_ratio',0):.0f}%, 매출성장 {d.get('revenue_growth',0):.1f}%") + if d.get("intrinsic_value"): + context_parts.append( + f"DCF 내재가치 {int(d['intrinsic_value']):,}원, 안전마진 {d.get('margin_of_safety',0):.0f}%") + if d.get("us_pair_top"): + context_parts.append(f"미국 페어: {d['us_pair_top']}") + if d.get("top_reasons"): + context_parts.append(f"근거: {d['top_reasons']}") + else: + # 시장 전반 + buys = await db_top_buys(5) + sells = await db_top_sells(3) + m = await db_market_regime() + regime = (m.get("regime") or {}) + macro = m.get("macro") or {} + context_parts.append(f"[시장 상황] 레짐: {regime.get('regime','?')}") + if macro: + context_parts.append( + f"환율 {macro.get('usdkrw',0):.0f}원, 10년물 {macro.get('kor_10y',0):.2f}%, " + f"KOSPI {macro.get('kospi',0):,.0f}") + if buys: + context_parts.append("[오늘 매수 추천 top]") + for r in buys[:5]: + context_parts.append(f"- {r['stock_name']}({r['stock_code']}) {r['total_score']:.0f}점 {r['recommendation']}") + if sells: + context_parts.append("[오늘 매도 신호]") + for r in sells[:3]: + context_parts.append(f"- {r['stock_name']}({r['stock_code']}) {r['total_score']:.0f}점 {r['recommendation']}") + + context_str = "\n".join(context_parts) if context_parts else "(관련 데이터 없음)" + answer = await ask_exaone(text, context_str) + # 답변 길이 제한 + if len(answer) > 3500: + answer = answer[:3500] + "..." + await update.message.reply_text(answer) + + +# ───────────────────────────────────────────────────────────── +# 부트 +# ───────────────────────────────────────────────────────────── +async def post_init(app: Application): + global pg_pool + pg_pool = await asyncpg.create_pool(**PG, min_size=2, max_size=5) + # 커맨드 메뉴 등록 + await app.bot.set_my_commands([ + BotCommand("buy", "오늘 매수 추천"), + BotCommand("sell", "오늘 매도 신호"), + BotCommand("stock", "종목 상세 (예: /stock 005930)"), + BotCommand("market", "시장 상황"), + BotCommand("help", "도움말"), + ]) + logger.info("telegram-bot.ready") + + +def main(): + if not TG_TOKEN: + logger.error("missing TELEGRAM_BOT_TOKEN") + return + app = Application.builder().token(TG_TOKEN).post_init(post_init).build() + # 한국어/영어 명령어 둘 다 + app.add_handler(CommandHandler("start", cmd_start)) + app.add_handler(CommandHandler("help", cmd_help)) + app.add_handler(CommandHandler(["buy", "buys"], cmd_buys)) + app.add_handler(CommandHandler(["sell", "sells"], cmd_sells)) + app.add_handler(CommandHandler(["stock"], cmd_stock)) + app.add_handler(CommandHandler(["deep"], cmd_deep)) + app.add_handler(CommandHandler(["market"], cmd_market)) + app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_text)) + logger.info("telegram-bot.start", model=EXAONE_MODEL) + app.run_polling(close_loop=False) + + +if __name__ == "__main__": + main() diff --git a/telegram-bot/requirements.txt b/telegram-bot/requirements.txt new file mode 100644 index 0000000..da8537d --- /dev/null +++ b/telegram-bot/requirements.txt @@ -0,0 +1,5 @@ +python-telegram-bot[asyncio]==21.5 +asyncpg==0.29.0 +httpx==0.27.0 +structlog==24.2.0 +orjson==3.10.3 diff --git a/trading-dashboard.jsx b/trading-dashboard.jsx new file mode 100644 index 0000000..a302530 --- /dev/null +++ b/trading-dashboard.jsx @@ -0,0 +1,268 @@ +import { useState, useEffect, useCallback } from "react"; + +const API = "http://192.168.0.60:8989/api"; + +const SENTIMENT_MAP = { "호재": { color: "#00E676", icon: "▲", bg: "rgba(0,230,118,0.08)" }, "악재": { color: "#FF5252", icon: "▼", bg: "rgba(255,82,82,0.08)" }, "중립": { color: "#78909C", icon: "●", bg: "rgba(120,144,156,0.06)" } }; +const REC_MAP = { "강력매수": { color: "#00E676", weight: 900 }, "매수관심": { color: "#69F0AE", weight: 700 }, "관망": { color: "#78909C", weight: 500 }, "매도관심": { color: "#FF8A80", weight: 700 }, "강력매도": { color: "#FF5252", weight: 900 } }; + +function useFetch(endpoint, interval = 60000) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const load = useCallback(() => { + fetch(`${API}${endpoint}`).then(r => r.json()).then(d => { setData(d); setLoading(false); }).catch(() => setLoading(false)); + }, [endpoint]); + useEffect(() => { load(); const t = setInterval(load, interval); return () => clearInterval(t); }, [load, interval]); + return { data, loading, reload: load }; +} + +function formatTime(ts) { + if (!ts) return "-"; + const d = new Date(ts); + const now = new Date(); + const diff = (now - d) / 1000 / 60; + if (diff < 60) return `${Math.floor(diff)}분 전`; + if (diff < 1440) return `${Math.floor(diff / 60)}시간 전`; + return d.toLocaleDateString("ko-KR", { month: "short", day: "numeric" }); +} + +function ScoreBar({ value, max = 100 }) { + const pct = Math.min(100, Math.max(0, ((value + max) / (2 * max)) * 100)); + const color = value >= 30 ? "#00E676" : value >= -30 ? "#78909C" : "#FF5252"; + return ( +

+
+
+ ); +} + +function SummaryCard({ icon, label, value, sub, color }) { + return ( +
+
{icon} {label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + +function Header({ onRefresh }) { + const [time, setTime] = useState(new Date()); + useEffect(() => { const t = setInterval(() => setTime(new Date()), 1000); return () => clearInterval(t); }, []); + return ( +
+
+

+ Trading AI Dashboard +

+
뉴스 + DART 공시 기반 종목 분석 시스템
+
+
+
+ {time.toLocaleString("ko-KR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })} +
+ +
+
+ ); +} + +function RecommendationCard({ item }) { + const rec = REC_MAP[item.recommendation] || REC_MAP["관망"]; + return ( +
+
+
+
{item.stock_name}
+
{item.stock_code}
+
+
+ {item.recommendation} +
+
+
+ 종합 {item.total_score?.toFixed(1)} + 뉴스 {item.news_score?.toFixed(0)} + 공시 {item.dart_score?.toFixed(0)} + 가격 {item.price_score?.toFixed(0)} +
+ + {item.top_reasons && ( +
+ {item.top_reasons.split("|")[0]?.trim().substring(0, 120)}... +
+ )} +
+ ); +} + +function NewsItem({ item }) { + const s = SENTIMENT_MAP[item.sentiment] || SENTIMENT_MAP["중립"]; + return ( +
+
+
{s.icon}
+
{item.intensity || 0}
+
+
+
+ {item.title} +
+
+ {item.primary_stock && {item.primary_stock}} + {item.source} + {formatTime(item.analyzed_at)} +
+ {item.reason &&
{item.reason.substring(0, 100)}
} +
+
+ {item.investment_action || "관망"} +
+
+ ); +} + +function AlertItem({ item }) { + const s = SENTIMENT_MAP[item.sentiment] || SENTIMENT_MAP["중립"]; + return ( +
+
+
+ {s.icon} {item.sentiment} (강도 {item.intensity}) +
+
{formatTime(item.analyzed_at)}
+
+
{item.title}
+
{item.reason?.substring(0, 100)}
+
+ ); +} + +function Tab({ active, label, onClick }) { + return ( + + ); +} + +export default function Dashboard() { + const [tab, setTab] = useState("overview"); + const { data: summary, reload: r1 } = useFetch("/summary", 30000); + const { data: ranking } = useFetch("/ranking", 60000); + const { data: recs, reload: r2 } = useFetch("/recommendations", 60000); + const { data: recent } = useFetch("/recent?limit=30", 30000); + const { data: alerts } = useFetch("/alerts", 30000); + const { data: timeline } = useFetch("/timeline?hours=48", 60000); + + const refresh = () => { r1(); r2(); }; + + return ( +
+ + +
+
+ + {/* Tabs */} +
+ setTab("overview")} /> + setTab("recommend")} /> + setTab("news")} /> + setTab("alerts")} /> +
+ + {/* Overview */} + {tab === "overview" && ( +
+ {/* Summary Cards */} +
+ + + + + +
+ + {/* Two column: Ranking + Recent */} +
+ {/* Ranking */} +
+

🏆 종목 랭킹 (최신)

+ {ranking && ranking.length > 0 ? ranking.slice(0, 10).map((item, i) => ( +
+
{i + 1}
+
+
{item.stock_name}
+
{item.stock_code}
+
+
+
{item.total_score?.toFixed(1)}
+
{item.recommendation}
+
+
+ )) :
데이터 수집 중...
} +
+ + {/* Recent News */} +
+

📰 최근 분석

+ {recent && recent.length > 0 ? recent.slice(0, 15).map((item, i) => ( + + )) :
데이터 수집 중...
} +
+
+
+ )} + + {/* Recommendations */} + {tab === "recommend" && ( +
+
뉴스 감성 + DART 공시 + 가격 모멘텀 기반 종합 점수
+
+ {recs && recs.length > 0 ? recs.map((item, i) => ( + + )) :
추천 데이터가 아직 없습니다. 16:30 자동 산출 후 업데이트됩니다.
} +
+
+ )} + + {/* News Feed */} + {tab === "news" && ( +
+

전체 뉴스 피드

+ {recent && recent.length > 0 ? recent.map((item, i) => ( + + )) :
뉴스 데이터 수집 중...
} +
+ )} + + {/* Alerts */} + {tab === "alerts" && ( +
+
강도 3 이상 뉴스/공시 (최근 24시간)
+ {alerts && alerts.length > 0 ? alerts.map((item, i) => ( + + )) :
현재 긴급 알림이 없습니다.
} +
+ )} + + {/* Footer */} +
+ Trading AI System • 뉴스/공시 기반 자동 분석 • 투자 판단의 참고 자료로만 활용하세요 +
+
+
+ ); +} diff --git a/us-market/Dockerfile b/us-market/Dockerfile new file mode 100644 index 0000000..e72b26a --- /dev/null +++ b/us-market/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.11-slim +WORKDIR /app +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* +COPY requirements.txt . +RUN pip install --no-cache-dir --default-timeout=180 --retries=5 -r requirements.txt +COPY . . +EXPOSE 8383 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8383", "--workers", "1", "--log-level", "info"] diff --git a/us-market/main.py b/us-market/main.py new file mode 100644 index 0000000..c36f979 --- /dev/null +++ b/us-market/main.py @@ -0,0 +1,838 @@ +""" +US Market Sync Service (port 8383, 172.30.0.24) + +미국증시 → 한국증시 동조 시그널 생성: + A) 섹터 ETF 동조 (SOXX, XBI, LIT 등 14개) → 한국 같은 섹터에 ±5점 + B) 개별 페어 (NVDA↔SK하이닉스 등) 60일 회귀 베타 → ±10점 + D) 자동 페어 발굴 (코스피200 × S&P500 60일 상관계수) + +매일 KST 07:30 미국 정규장 마감 후 수집, 08:00 시그널 계산. +""" +import os +import asyncio +import json +from datetime import date, datetime, timedelta +from typing import Optional, List, Dict + +import asyncpg +import orjson +import structlog +from fastapi import FastAPI, Query, BackgroundTasks +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from pytz import timezone + +import httpx +import pandas as pd +import numpy as np +from scipy import stats + +# ───────────────────────────────────────────────────────────── +# 설정 +# ───────────────────────────────────────────────────────────── +PG = { + "host": os.getenv("POSTGRES_HOST", "postgres"), + "port": int(os.getenv("POSTGRES_PORT", 5432)), + "database": os.getenv("POSTGRES_DB", "trading_ai"), + "user": os.getenv("POSTGRES_USER", "kyu"), + "password": os.getenv("POSTGRES_PASSWORD", ""), +} +KST = timezone("Asia/Seoul") +FINNHUB_KEY = os.getenv("FINNHUB_API_KEY", "") +FINNHUB_BASE = "https://finnhub.io/api/v1" +AV_KEY = os.getenv("ALPHAVANTAGE_API_KEY", "") +AV_BASE = "https://www.alphavantage.co/query" +AV_DAILY_LIMIT = 25 # free tier: 25 calls/day + +logger = structlog.get_logger() +app = FastAPI(title="US Market Sync") +pg_pool: Optional[asyncpg.Pool] = None +scheduler = AsyncIOScheduler(timezone=KST) + +# ───────────────────────────────────────────────────────────── +# ETF → 한국 섹터 키워드 매핑 (dart_corps.sector LIKE 매칭용) +# 섹터 컬럼은 KSIC 한글 (예: "반도체 및 전자집적회로 제조업") +# ───────────────────────────────────────────────────────────── +SECTOR_ETFS: Dict[str, Dict] = { + "SOXX": {"keywords": ["반도체", "전자집적", "전자부품"], + "desc": "iShares Semiconductor"}, + "SMH": {"keywords": ["반도체", "전자집적"], + "desc": "VanEck Semiconductor"}, + "XLK": {"keywords": ["소프트웨어", "정보서비스", "컴퓨터"], + "desc": "Tech Select Sector"}, + "QQQ": {"keywords": ["소프트웨어", "인터넷", "포털"], + "desc": "Nasdaq 100"}, + "XBI": {"keywords": ["바이오", "생물의약", "의약품"], + "desc": "S&P Biotech"}, + "IBB": {"keywords": ["바이오", "생물의약"], + "desc": "Nasdaq Biotech"}, + "LIT": {"keywords": ["전지", "축전지", "이차전지"], + "desc": "Global Lithium"}, + "XLE": {"keywords": ["석유", "정제", "가스", "원유"], + "desc": "Energy Select"}, + "XLF": {"keywords": ["은행", "보험", "증권", "금융"], + "desc": "Financial Select"}, + "XLV": {"keywords": ["의료", "병원", "의료용기기"], + "desc": "Health Care Select"}, + "XLI": {"keywords": ["기계", "산업용", "건설"], + "desc": "Industrials Select"}, + "XLP": {"keywords": ["식품", "음료", "가공식품"], + "desc": "Consumer Staples"}, + "XLY": {"keywords": ["자동차", "여가", "의류", "백화점"], + "desc": "Consumer Discretionary"}, + "ITA": {"keywords": ["항공", "방위", "조선"], + "desc": "Aerospace & Defense"}, +} + +# ───────────────────────────────────────────────────────────── +# 검증된 핵심 페어 (seed) — 학술/시장 통념상 강한 동조 +# (us_ticker, kr_code, kr_name_hint) kr_name_hint는 검증용 코멘트 +# ───────────────────────────────────────────────────────────── +SEED_PAIRS: List = [ + # 반도체 + ("NVDA", "000660"), # SK하이닉스 + ("NVDA", "005930"), # 삼성전자 + ("AMD", "000660"), + ("AMD", "005930"), + ("MU", "000660"), + ("INTC", "005930"), + ("TSM", "000660"), + ("TSM", "005930"), + # 2차전지 + ("TSLA", "373220"), # LG에너지솔루션 + ("TSLA", "247540"), # 에코프로비엠 + ("TSLA", "006400"), # 삼성SDI + ("ALB", "006400"), # 알버말 (리튬) + ("ALB", "373220"), + # 자동차 + ("F", "005380"), # 현대차 + ("GM", "000270"), # 기아 + ("TSLA", "012330"), # 현대모비스 + ("TM", "005380"), # 도요타→현대차 + # 인터넷/IT + ("GOOGL","035420"), # NAVER + ("META", "035720"), # 카카오 + ("AAPL", "011070"), # LG이노텍 + ("AAPL", "009150"), # 삼성전기 + # 바이오/제약 + ("PFE", "068270"), # 셀트리온 + ("MRK", "207940"), # 삼성바이오로직스 + # 조선/방산 + ("LMT", "079550"), # LIG넥스원 + ("LMT", "329180"), # HD현대중공업 + # 철강/소재 + ("NUE", "005490"), # POSCO홀딩스 + # 화학 + ("DOW", "051910"), # LG화학 + ("LYB", "011170"), # 롯데케미칼 + # 게임/엔터 + ("NTES", "036570"), # 엔씨소프트 + ("NTES", "251270"), # 넷마블 + ("DIS", "035250"), # 강원랜드 (엔터) + # 유통 + ("AMZN", "139480"), # 이마트 +] + +# 자동 발굴용 미국 종목 후보 (S&P500 대표 + 한국 영향 큰 종목) +DISCOVERY_US_TICKERS = [ + # 반도체 + "NVDA", "AMD", "MU", "INTC", "TSM", "AVGO", "QCOM", "TXN", "AMAT", "LRCX", "KLAC", + # 빅테크 + "AAPL", "MSFT", "GOOGL", "AMZN", "META", "TSLA", "NFLX", "ORCL", "ADBE", "CRM", + # 금융 + "JPM", "BAC", "GS", "MS", "WFC", "C", "BLK", + # 에너지/소재 + "XOM", "CVX", "COP", "NUE", "FCX", + # 헬스/바이오 + "JNJ", "PFE", "MRK", "ABBV", "LLY", "BMY", "GILD", + # 소비재/유통 + "WMT", "COST", "HD", "MCD", "NKE", "SBUX", "DIS", + # 산업/방산 + "BA", "LMT", "RTX", "GD", "CAT", "DE", + # 자동차 + "F", "GM", "TM", + # 2차전지/리튬 + "ALB", "RIVN", + # 화학 + "DOW", "LYB", +] + +# ───────────────────────────────────────────────────────────── +# DB 초기화 +# ───────────────────────────────────────────────────────────── +DDL = """ +CREATE TABLE IF NOT EXISTS us_market_daily ( + ticker VARCHAR(20), + trade_date DATE, + open_price DOUBLE PRECISION, + close_price DOUBLE PRECISION, + prev_close DOUBLE PRECISION, + change_pct DOUBLE PRECISION, + volume BIGINT, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (ticker, trade_date) +); +CREATE INDEX IF NOT EXISTS idx_us_daily_ticker ON us_market_daily(ticker, trade_date DESC); + +CREATE TABLE IF NOT EXISTS us_sector_etf_map ( + etf_ticker VARCHAR(20) PRIMARY KEY, + sector_keywords TEXT[], + description TEXT, + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS us_kr_pairs ( + us_ticker VARCHAR(20), + kr_code VARCHAR(10), + beta_60d DOUBLE PRECISION, + correlation_60d DOUBLE PRECISION, + sample_size INTEGER, + source VARCHAR(20) DEFAULT 'seed', + updated_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (us_ticker, kr_code) +); +CREATE INDEX IF NOT EXISTS idx_pairs_kr ON us_kr_pairs(kr_code); + +CREATE TABLE IF NOT EXISTS us_overnight_signal ( + kr_code VARCHAR(10), + signal_date DATE, + sector_adj DOUBLE PRECISION DEFAULT 0, + pair_adj DOUBLE PRECISION DEFAULT 0, + total_adj DOUBLE PRECISION DEFAULT 0, + contributing_pairs JSONB, + created_at TIMESTAMP DEFAULT NOW(), + PRIMARY KEY (kr_code, signal_date) +); +CREATE INDEX IF NOT EXISTS idx_overnight_date ON us_overnight_signal(signal_date DESC); +""" + +# ───────────────────────────────────────────────────────────── +# 시작/종료 +# ───────────────────────────────────────────────────────────── +@app.on_event("startup") +async def on_start(): + global pg_pool + pg_pool = await asyncpg.create_pool(**PG, min_size=2, max_size=10) + async with pg_pool.acquire() as conn: + await conn.execute(DDL) + await seed_etfs_and_pairs() + + scheduler.add_job( + collect_us_daily, CronTrigger(hour=7, minute=30), + id="us_collect", replace_existing=True) + scheduler.add_job( + calc_overnight_signals_all, CronTrigger(hour=8, minute=0), + id="overnight_calc", replace_existing=True) + scheduler.add_job( + recalc_pair_betas, CronTrigger(day_of_week="sun", hour=2, minute=0), + id="pair_beta", replace_existing=True) + scheduler.add_job( + discover_new_pairs, CronTrigger(day_of_week="sun", hour=3, minute=0), + id="pair_discover", replace_existing=True) + # 매일 06:00 Alpha Vantage 백필 (25 ticker/일, 60일+ 채워질 때까지) + scheduler.add_job( + backfill_yfinance, CronTrigger(hour=6, minute=0), + id="av_backfill", replace_existing=True) + scheduler.start() + logger.info("us-market.started") + + +@app.on_event("shutdown") +async def on_stop(): + if scheduler.running: + scheduler.shutdown() + if pg_pool: + await pg_pool.close() + + +# ───────────────────────────────────────────────────────────── +# Seed: ETF + 핵심 페어 등록 +# ───────────────────────────────────────────────────────────── +async def seed_etfs_and_pairs(): + async with pg_pool.acquire() as conn: + for etf, meta in SECTOR_ETFS.items(): + await conn.execute(""" + INSERT INTO us_sector_etf_map (etf_ticker, sector_keywords, description) + VALUES ($1, $2, $3) + ON CONFLICT (etf_ticker) DO UPDATE + SET sector_keywords=$2, description=$3, updated_at=NOW() + """, etf, meta["keywords"], meta["desc"]) + for us, kr in SEED_PAIRS: + await conn.execute(""" + INSERT INTO us_kr_pairs (us_ticker, kr_code, source) + VALUES ($1, $2, 'seed') + ON CONFLICT (us_ticker, kr_code) DO NOTHING + """, us, kr) + logger.info("seed.done", etfs=len(SECTOR_ETFS), pairs=len(SEED_PAIRS)) + + +# ───────────────────────────────────────────────────────────── +# Finnhub 헬퍼 — /quote (무료 OK, /stock/candle은 2024년부터 유료) +# 응답: c(current), pc(prev_close), dp(percent), o(open), h, l, t +# Free tier: 60 calls/분 +# ───────────────────────────────────────────────────────────── +async def fetch_finnhub_quote(client: httpx.AsyncClient, ticker: str + ) -> Optional[dict]: + """Finnhub /quote 호출. 실패 시 None.""" + if not FINNHUB_KEY: + return None + try: + r = await client.get(f"{FINNHUB_BASE}/quote", params={ + "symbol": ticker, "token": FINNHUB_KEY, + }, timeout=15) + except Exception as e: + logger.warning("finnhub.req_err", ticker=ticker, err=str(e)) + return None + if r.status_code == 429: + await asyncio.sleep(5) + return None + if r.status_code != 200: + return None + try: + j = r.json() + except Exception: + return None + # 휴장/잘못된 ticker 시 c=0 + if not j or j.get("c", 0) <= 0: + return None + return j + + +# ───────────────────────────────────────────────────────────── +# 수집: /quote으로 미국 일간 종가 누적 +# 매일 호출하면 us_market_daily에 시계열 점진 누적 → 베타 학습 가능 +# ───────────────────────────────────────────────────────────── +async def collect_us_daily(days: int = 1): + """매일 KST 07:30 호출. days 인자는 호환용(무시) — /quote은 단일 시점.""" + if not FINNHUB_KEY: + logger.error("us.no_api_key") + return {"saved": 0, "err": "FINNHUB_API_KEY missing — set in .env"} + + tickers = sorted( + set(SECTOR_ETFS.keys()) + | {t for t, _ in SEED_PAIRS} + | set(DISCOVERY_US_TICKERS) + ) + saved = 0 + failed: List[str] = [] + async with httpx.AsyncClient() as client: + for i, ticker in enumerate(tickers): + q = await fetch_finnhub_quote(client, ticker) + if not q: + failed.append(ticker) + else: + # t 타임스탬프(미국 ET 종가 시점) → trade_date + ts = q.get("t", 0) + trade_dt = (datetime.fromtimestamp(ts).date() if ts + else date.today() - timedelta(days=1)) + async with pg_pool.acquire() as conn: + await conn.execute(""" + INSERT INTO us_market_daily + (ticker, trade_date, open_price, close_price, + prev_close, change_pct, volume) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (ticker, trade_date) DO UPDATE + SET open_price=$3, close_price=$4, prev_close=$5, + change_pct=$6 + """, ticker, trade_dt, + float(q.get("o", 0) or 0), float(q["c"]), + float(q.get("pc", 0) or 0), float(q.get("dp", 0) or 0), + 0) + saved += 1 + # Rate limit: 60/분 → 1.1초 간격 + if i < len(tickers) - 1: + await asyncio.sleep(1.1) + logger.info("us.collected", rows=saved, ok=len(tickers) - len(failed), + failed=len(failed)) + return {"saved": saved, "tickers": len(tickers), + "ok": len(tickers) - len(failed), "failed": failed[:10]} + + +# ───────────────────────────────────────────────────────────── +# Alpha Vantage 백필 — TIME_SERIES_DAILY로 ticker당 100일 히스토리. +# Free tier 25 calls/day → 자동으로 매일 25개씩 분할 처리 (75 ticker × 3일). +# ───────────────────────────────────────────────────────────── +async def fetch_av_daily(client: httpx.AsyncClient, ticker: str + ) -> Optional[List[dict]]: + """Alpha Vantage TIME_SERIES_DAILY 호출. 실패/한도초과 시 None.""" + try: + r = await client.get(AV_BASE, params={ + "function": "TIME_SERIES_DAILY", + "symbol": ticker, + "outputsize": "compact", # 100일치 (full=20+년) + "apikey": AV_KEY, + }, timeout=20) + except Exception as e: + logger.warning("av.req_err", ticker=ticker, err=str(e)) + return None + if r.status_code != 200: + return None + try: + j = r.json() + except Exception: + return None + if "Note" in j or "Information" in j or "Error Message" in j: + logger.warning("av.limit_or_err", + ticker=ticker, msg=str(j)[:200]) + return None + ts = j.get("Time Series (Daily)") + if not ts: + return None + rows = [] + for dt_str, ohlcv in ts.items(): + try: + rows.append({ + "trade_date": date.fromisoformat(dt_str), + "open": float(ohlcv["1. open"]), + "high": float(ohlcv["2. high"]), + "low": float(ohlcv["3. low"]), + "close": float(ohlcv["4. close"]), + "volume": int(float(ohlcv["5. volume"])), + }) + except (KeyError, ValueError): + continue + rows.sort(key=lambda x: x["trade_date"]) + return rows + + +async def backfill_yfinance(days: int = 180, max_tickers: int = 0): + """Alpha Vantage로 히스토리 백필. + - max_tickers=0 (기본): AV_DAILY_LIMIT(=25)개만 처리 → 일일 한도 자동 준수 + - max_tickers>0: 명시값 사용 + - 이미 60일+ 데이터 있는 ticker는 건너뜀 → 3일치 분산 자동 진행 + """ + if not AV_KEY: + return {"saved": 0, "err": "ALPHAVANTAGE_API_KEY missing — set in .env"} + all_tickers = sorted( + set(SECTOR_ETFS.keys()) + | {t for t, _ in SEED_PAIRS} + | set(DISCOVERY_US_TICKERS) + ) + # 이미 60일+ 누적된 ticker는 스킵 + async with pg_pool.acquire() as conn: + rows = await conn.fetch( + "SELECT ticker, COUNT(*) AS n FROM us_market_daily " + "GROUP BY ticker HAVING COUNT(*) >= 60") + done = {r["ticker"] for r in rows} + pending = [t for t in all_tickers if t not in done] + limit = max_tickers if max_tickers > 0 else AV_DAILY_LIMIT + targets = pending[:limit] + + saved = 0 + failed: List[str] = [] + async with httpx.AsyncClient() as client: + for i, ticker in enumerate(targets): + rows = await fetch_av_daily(client, ticker) + if not rows: + failed.append(ticker) + # 한도 초과면 즉시 중단 + if i > 0 and len(failed) > 3 and len(failed) > i // 2: + logger.warning("av.likely_quota", processed=i) + break + else: + prev_close = None + async with pg_pool.acquire() as conn: + async with conn.transaction(): + for row in rows: + pc = prev_close if prev_close is not None else 0.0 + dp = ((row["close"] - pc) / pc * 100.0) if pc > 0 else 0.0 + await conn.execute(""" + INSERT INTO us_market_daily + (ticker, trade_date, open_price, close_price, + prev_close, change_pct, volume) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (ticker, trade_date) DO UPDATE + SET open_price=$3, close_price=$4, prev_close=$5, + change_pct=$6, volume=$7 + """, ticker, row["trade_date"], row["open"], + row["close"], pc, dp, row["volume"]) + saved += 1 + prev_close = row["close"] + # AV free tier 5 calls/min → 12초 간격 + if i < len(targets) - 1: + await asyncio.sleep(12.5) + + logger.info("us.av_backfill", saved=saved, + processed=len(targets) - len(failed), + failed=len(failed), + pending_remaining=len(pending) - len(targets) + len(failed)) + return {"saved": saved, "processed": len(targets), + "ok": len(targets) - len(failed), + "failed": failed[:10], + "pending_after": max(0, len(pending) - len(targets) + len(failed)), + "source": "alphavantage"} + + +# ───────────────────────────────────────────────────────────── +# 시그널 계산: 한국 종목별 overnight 보정 점수 +# ───────────────────────────────────────────────────────────── +async def calc_overnight_signals_all(target_date: Optional[date] = None): + """오늘 자(=어제 미국장 마감) 보정 점수 계산. + + A. 섹터 ETF 동조: ETF change_pct → 같은 sector 한국 종목에 ±5점 + B. 페어 동조: 페어별 미국주 change_pct × beta → ±10점 (집계) + """ + target = target_date or date.today() + async with pg_pool.acquire() as conn: + # 1) 최신 미국 거래일의 ETF/주식 change_pct + us_rows = await conn.fetch(""" + SELECT DISTINCT ON (ticker) + ticker, trade_date, change_pct + FROM us_market_daily + WHERE trade_date <= $1 AND change_pct IS NOT NULL + ORDER BY ticker, trade_date DESC + """, target) + us_chg = {r["ticker"]: float(r["change_pct"]) for r in us_rows} + if not us_chg: + logger.warning("overnight.no_us_data") + return {"saved": 0, "err": "no us data"} + + # 2) 활성 한국 종목 + 섹터 + kr_rows = await conn.fetch(""" + SELECT stock_code, sector FROM dart_corps WHERE is_active=true + """) + + # 3) 페어 매핑 (us_ticker → list of (kr_code, beta)) + pair_rows = await conn.fetch(""" + SELECT us_ticker, kr_code, beta_60d, correlation_60d + FROM us_kr_pairs + """) + pairs_by_kr: Dict[str, List] = {} + for r in pair_rows: + kr = r["kr_code"] + pairs_by_kr.setdefault(kr, []).append({ + "us": r["us_ticker"], + "beta": float(r["beta_60d"]) if r["beta_60d"] else 1.0, + "corr": float(r["correlation_60d"]) if r["correlation_60d"] else 0.0, + }) + + # 4) ETF 매핑 + etf_rows = await conn.fetch("SELECT etf_ticker, sector_keywords FROM us_sector_etf_map") + + saved = 0 + for kr in kr_rows: + code = kr["stock_code"] + sector = (kr["sector"] or "") + + # A. 섹터 ETF 동조 + sector_adj = 0.0 + matched_etfs = [] + for er in etf_rows: + kws = er["sector_keywords"] or [] + if not sector or not kws: + continue + if any(kw and kw in sector for kw in kws): + pct = us_chg.get(er["etf_ticker"]) + if pct is None: + continue + matched_etfs.append({"etf": er["etf_ticker"], "pct": pct}) + if matched_etfs: + # 매칭된 ETF 평균 변동률 → ±5 클램프 + avg_pct = sum(m["pct"] for m in matched_etfs) / len(matched_etfs) + sector_adj = max(-5.0, min(5.0, avg_pct * 1.5)) + + # B. 페어 베타 기반 + pair_adj = 0.0 + contributing = [] + kr_pairs = pairs_by_kr.get(code, []) + for p in kr_pairs: + pct = us_chg.get(p["us"]) + if pct is None: + continue + # 예상 갭 = 미국주 변동률 × 베타 + exp_gap = pct * p["beta"] + # 상관계수 가중 (|corr|이 낮으면 신뢰도 ↓) + weight = max(0.3, abs(p["corr"])) if p["corr"] else 0.5 + contrib = exp_gap * weight + pair_adj += contrib + contributing.append({ + "us": p["us"], "pct": round(pct, 2), + "beta": round(p["beta"], 2), + "corr": round(p["corr"], 2), + "contribution": round(contrib, 2), + }) + if contributing: + # 다중 페어 평균 + 클램프 + pair_adj = max(-10.0, min(10.0, pair_adj / len(contributing) * 2.0)) + + total_adj = sector_adj + pair_adj + if abs(total_adj) < 0.1 and not matched_etfs and not contributing: + continue # 영향 없는 종목은 저장 스킵 + + await conn.execute(""" + INSERT INTO us_overnight_signal + (kr_code, signal_date, sector_adj, pair_adj, total_adj, contributing_pairs) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (kr_code, signal_date) DO UPDATE + SET sector_adj=$3, pair_adj=$4, total_adj=$5, + contributing_pairs=$6, created_at=NOW() + """, code, target, sector_adj, pair_adj, total_adj, + json.dumps({"etfs": matched_etfs, "pairs": contributing})) + saved += 1 + logger.info("overnight.calculated", saved=saved, date=str(target)) + return {"saved": saved, "date": str(target)} + + +# ───────────────────────────────────────────────────────────── +# 페어 베타 재계산 (주 1회 일요일) +# ───────────────────────────────────────────────────────────── +async def recalc_pair_betas(window_days: int = 60): + """등록된 페어에 대해 최근 N일 일간수익률로 회귀 → beta, correlation 갱신.""" + since = date.today() - timedelta(days=window_days * 2) # 거래일 여유 + + async with pg_pool.acquire() as conn: + pairs = await conn.fetch("SELECT us_ticker, kr_code FROM us_kr_pairs") + updated, skipped = 0, 0 + for p in pairs: + us_t, kr_c = p["us_ticker"], p["kr_code"] + # 미국 시계열 + us_rows = await conn.fetch(""" + SELECT trade_date, close_price FROM us_market_daily + WHERE ticker=$1 AND trade_date >= $2 ORDER BY trade_date + """, us_t, since) + if len(us_rows) < 30: + skipped += 1 + continue + # 한국 시계열 (stock_prices.collected_at) + kr_rows = await conn.fetch(""" + SELECT collected_at::date AS dt, + AVG(price)::float AS close + FROM stock_prices + WHERE stock_code=$1 AND collected_at::date >= $2 + GROUP BY collected_at::date ORDER BY dt + """, kr_c, since) + if len(kr_rows) < 30: + skipped += 1 + continue + + us_df = pd.DataFrame([(r["trade_date"], r["close_price"]) for r in us_rows], + columns=["dt", "us"]) + kr_df = pd.DataFrame([(r["dt"], r["close"]) for r in kr_rows], + columns=["dt", "kr"]) + us_df["dt"] = pd.to_datetime(us_df["dt"]) + kr_df["dt"] = pd.to_datetime(kr_df["dt"]) + + # 한국 종가는 미국 다음날에 영향 받음 → 미국 시계열을 +1일 시프트해서 매칭 + us_df["dt"] = us_df["dt"] + pd.Timedelta(days=1) + merged = pd.merge(us_df, kr_df, on="dt", how="inner") + if len(merged) < window_days // 2: + skipped += 1 + continue + + us_ret = merged["us"].pct_change().dropna() + kr_ret = merged["kr"].pct_change().dropna() + n = min(len(us_ret), len(kr_ret)) + if n < 20: + skipped += 1 + continue + us_ret, kr_ret = us_ret.iloc[-n:].values, kr_ret.iloc[-n:].values + + # 회귀: kr_ret = beta * us_ret + intercept + slope, _, r_val, _, _ = stats.linregress(us_ret, kr_ret) + beta = float(slope) + corr = float(r_val) + await conn.execute(""" + UPDATE us_kr_pairs + SET beta_60d=$1, correlation_60d=$2, sample_size=$3, updated_at=NOW() + WHERE us_ticker=$4 AND kr_code=$5 + """, beta, corr, n, us_t, kr_c) + updated += 1 + logger.info("pair_beta.recalc", updated=updated, skipped=skipped) + return {"updated": updated, "skipped": skipped} + + +# ───────────────────────────────────────────────────────────── +# 자동 페어 발굴 (월 1회) +# 코스피200 시총상위 50개 × DISCOVERY_US 후보 → |corr|≥0.5인 것만 등록 +# ───────────────────────────────────────────────────────────── +async def discover_new_pairs(min_abs_corr: float = 0.5, top_kr: int = 50, + window_days: int = 60): + since = date.today() - timedelta(days=window_days * 2) + async with pg_pool.acquire() as conn: + # 시총상위 한국 종목 + top_rows = await conn.fetch(""" + SELECT DISTINCT ON (stock_code) stock_code, market_cap + FROM stock_prices + WHERE collected_at::date >= CURRENT_DATE - INTERVAL '7 days' + AND market_cap > 0 + ORDER BY stock_code, collected_at DESC + """) + kr_top = sorted(top_rows, key=lambda r: -(r["market_cap"] or 0))[:top_kr] + added = 0 + for kr in kr_top: + kr_c = kr["stock_code"] + kr_rows = await conn.fetch(""" + SELECT collected_at::date AS dt, AVG(price)::float AS close + FROM stock_prices + WHERE stock_code=$1 AND collected_at::date >= $2 + GROUP BY collected_at::date ORDER BY dt + """, kr_c, since) + if len(kr_rows) < 30: + continue + kr_df = pd.DataFrame([(r["dt"], r["close"]) for r in kr_rows], + columns=["dt", "kr"]) + kr_df["dt"] = pd.to_datetime(kr_df["dt"]) + + for us_t in DISCOVERY_US_TICKERS: + us_rows = await conn.fetch(""" + SELECT trade_date, close_price FROM us_market_daily + WHERE ticker=$1 AND trade_date >= $2 ORDER BY trade_date + """, us_t, since) + if len(us_rows) < 30: + continue + us_df = pd.DataFrame([(r["trade_date"], r["close_price"]) for r in us_rows], + columns=["dt", "us"]) + us_df["dt"] = pd.to_datetime(us_df["dt"]) + pd.Timedelta(days=1) + merged = pd.merge(us_df, kr_df, on="dt", how="inner") + if len(merged) < window_days // 2: + continue + us_ret = merged["us"].pct_change().dropna() + kr_ret = merged["kr"].pct_change().dropna() + n = min(len(us_ret), len(kr_ret)) + if n < 20: + continue + slope, _, r_val, _, _ = stats.linregress( + us_ret.iloc[-n:].values, kr_ret.iloc[-n:].values) + if abs(r_val) < min_abs_corr: + continue + await conn.execute(""" + INSERT INTO us_kr_pairs + (us_ticker, kr_code, beta_60d, correlation_60d, sample_size, source) + VALUES ($1, $2, $3, $4, $5, 'discovered') + ON CONFLICT (us_ticker, kr_code) DO UPDATE + SET beta_60d=$3, correlation_60d=$4, sample_size=$5, updated_at=NOW() + """, us_t, kr_c, float(slope), float(r_val), n) + added += 1 + logger.info("pair_discover.done", added=added) + return {"added": added} + + +# ───────────────────────────────────────────────────────────── +# REST API +# ───────────────────────────────────────────────────────────── +@app.get("/health") +async def health(): + return {"ok": True, "service": "us-market", "ts": datetime.now(KST).isoformat()} + + +@app.post("/collect") +async def manual_collect(days: int = Query(default=7, ge=1, le=365), + bg: BackgroundTasks = None): + """수동 수집. days=7 일상, 백필은 180 권장.""" + if bg: + bg.add_task(collect_us_daily, days) + return {"status": "queued", "days": days} + return await collect_us_daily(days) + + +@app.post("/collect/backfill") +async def backfill(days: int = Query(default=180, ge=30, le=365), + bg: BackgroundTasks = None): + """대규모 백필 — 페어 베타 학습용 180일 권장. 76 ticker × 1.1초 ≈ 85초.""" + if bg: + bg.add_task(collect_us_daily, days) + return {"status": "queued", "days": days} + return await collect_us_daily(days) + + +@app.post("/collect/yfinance-backfill") +async def yfinance_backfill_ep(days: int = Query(default=180, ge=30, le=730), + bg: BackgroundTasks = None): + """yfinance로 일괄 히스토리 다운로드 (페어 베타 학습 초기 시드용).""" + if bg: + bg.add_task(backfill_yfinance, days) + return {"status": "queued", "days": days} + return await backfill_yfinance(days) + + +@app.post("/signal/calculate") +async def manual_signal(target: Optional[str] = None): + d = date.fromisoformat(target) if target else date.today() + return await calc_overnight_signals_all(d) + + +@app.get("/signal/{kr_code}") +async def get_signal(kr_code: str, days: int = Query(default=7, ge=1, le=90)): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT signal_date, sector_adj, pair_adj, total_adj, contributing_pairs + FROM us_overnight_signal + WHERE kr_code=$1 AND signal_date >= CURRENT_DATE - $2::int + ORDER BY signal_date DESC + """, kr_code, days) + return [dict(r) for r in rows] + + +@app.get("/signal/latest") +async def latest_signals(limit: int = Query(default=50, le=500)): + async with pg_pool.acquire() as conn: + rows = await conn.fetch(""" + SELECT DISTINCT ON (kr_code) + kr_code, signal_date, sector_adj, pair_adj, total_adj + FROM us_overnight_signal + ORDER BY kr_code, signal_date DESC + LIMIT $1 + """, limit) + return [dict(r) for r in rows] + + +@app.get("/pairs") +async def list_pairs(kr_code: Optional[str] = None, us_ticker: Optional[str] = None): + async with pg_pool.acquire() as conn: + if kr_code: + rows = await conn.fetch( + "SELECT * FROM us_kr_pairs WHERE kr_code=$1 ORDER BY ABS(correlation_60d) DESC NULLS LAST", + kr_code) + elif us_ticker: + rows = await conn.fetch( + "SELECT * FROM us_kr_pairs WHERE us_ticker=$1 ORDER BY ABS(correlation_60d) DESC NULLS LAST", + us_ticker) + else: + rows = await conn.fetch( + "SELECT * FROM us_kr_pairs ORDER BY ABS(correlation_60d) DESC NULLS LAST LIMIT 200") + return [dict(r) for r in rows] + + +@app.post("/pairs/recalc-beta") +async def manual_recalc(window: int = Query(default=60, ge=20, le=250)): + return await recalc_pair_betas(window) + + +@app.post("/pairs/discover") +async def manual_discover(min_corr: float = Query(default=0.5, ge=0.1, le=0.9), + top_kr: int = Query(default=50, ge=10, le=200)): + return await discover_new_pairs(min_corr, top_kr) + + +@app.get("/etfs") +async def list_etfs(): + async with pg_pool.acquire() as conn: + rows = await conn.fetch("SELECT * FROM us_sector_etf_map ORDER BY etf_ticker") + return [dict(r) for r in rows] + + +@app.get("/etfs/{etf}/latest") +async def etf_latest(etf: str): + async with pg_pool.acquire() as conn: + row = await conn.fetchrow(""" + SELECT * FROM us_market_daily WHERE ticker=$1 + ORDER BY trade_date DESC LIMIT 1 + """, etf.upper()) + return dict(row) if row else {"err": "no data"} + + +@app.get("/stats") +async def stats_endpoint(): + async with pg_pool.acquire() as conn: + r1 = await conn.fetchrow( + "SELECT COUNT(*) AS rows, COUNT(DISTINCT ticker) AS tickers," + " MIN(trade_date) AS earliest, MAX(trade_date) AS latest FROM us_market_daily") + r2 = await conn.fetchrow( + "SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE source='seed') AS seed," + " COUNT(*) FILTER (WHERE source='discovered') AS discovered," + " COUNT(*) FILTER (WHERE beta_60d IS NOT NULL) AS with_beta" + " FROM us_kr_pairs") + r3 = await conn.fetchrow( + "SELECT COUNT(*) AS rows, COUNT(DISTINCT kr_code) AS codes," + " MAX(signal_date) AS latest FROM us_overnight_signal") + return {"us_daily": dict(r1), "pairs": dict(r2), "signals": dict(r3)} diff --git a/us-market/requirements.txt b/us-market/requirements.txt new file mode 100644 index 0000000..1f18d96 --- /dev/null +++ b/us-market/requirements.txt @@ -0,0 +1,11 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +asyncpg==0.29.0 +apscheduler==3.10.4 +structlog==24.2.0 +orjson==3.10.3 +httpx==0.27.0 +pandas==2.2.2 +numpy==1.26.4 +scipy==1.13.1 +pytz==2024.1