[2026-05-27] 포맷 후 복구 설치 스크립트 추가

This commit is contained in:
2026-05-27 16:53:52 +09:00
parent 04577c63f1
commit 29db1bfcab
135 changed files with 2909 additions and 251 deletions
+29
View File
@@ -0,0 +1,29 @@
import sys, os
sys.path.insert(0, '.')
from app.db.models import get_conn
from datetime import datetime
with get_conn() as c:
pos = c.execute('SELECT ticker, name, entry_time, entry_price, quantity, stop_price FROM positions').fetchall()
print('=== 현재 포지션 ===')
if pos:
for r in pos:
print(f' {r[1]}({r[0]}) {r[4]}주 @ {r[3]:,.0f}원 SL={r[5]:,.0f}원 진입={r[2]}')
else:
print(' 없음')
today = datetime.now().strftime('%Y-%m-%d')
with get_conn() as c:
trades = c.execute(
"SELECT name, exit_reason, pnl FROM trades WHERE date=? AND exit_time IS NOT NULL ORDER BY id",
(today,)
).fetchall()
print(f'\n=== 오늘 거래 ({len(trades)}건) ===')
wins = sum(1 for t in trades if t[2] and t[2] > 0)
losses = len(trades) - wins
for t in trades:
sign = '+' if t[2] and t[2] > 0 else ''
print(f' {t[0]:12} [{t[1]:4}] {sign}{t[2]:,.0f}')
print(f' 승:{wins} 패:{losses}')
+9
View File
@@ -0,0 +1,9 @@
import asyncio, sys, os
sys.path.insert(0, r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3')
from app.main import load_env; load_env()
from app.monitor.notifier import send
mode = os.getenv('KIS_MOCK', 'true')
dry = os.getenv('DRY_RUN', 'true')
label = '[모의투자]' if mode == 'true' else '[실거래]'
asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})'))
print('Discord 알림 전송 완료')
+25
View File
@@ -0,0 +1,25 @@
import subprocess, os
pid_file = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3\logs\bot.pid'
if os.path.exists(pid_file):
try:
pid = int(open(pid_file).read().strip())
subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True)
print(f'PID 파일 종료: {pid}')
except Exception as e:
print(f'PID 파일 종료 실패: {e}')
os.remove(pid_file)
r = subprocess.run(
['powershell', '-Command',
'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app/main.py*" -or $_.CommandLine -like "*app\\main.py*" } | Select-Object -ExpandProperty ProcessId'],
capture_output=True, text=True
)
pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()]
for pid in pids:
subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True)
print(f'잔존 프로세스 종료: {pid}')
if not pids:
print('실행 중인 봇 없음 — 새로 시작합니다')
+29
View File
@@ -0,0 +1,29 @@
import subprocess, os
pid_file = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3\logs\bot.pid'
# 1-a) PID 파일로 종료
if os.path.exists(pid_file):
try:
pid = int(open(pid_file).read().strip())
subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True)
print(f'PID 파일 종료: {pid}')
except Exception as e:
print(f'PID 파일 종료 실패: {e}')
os.remove(pid_file)
else:
print('PID 파일 없음')
# 1-b) Get-CimInstance로 잔존 프로세스 스캔
r = subprocess.run(
['powershell', '-Command',
'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app/main.py*" -or $_.CommandLine -like "*app\\main.py*" } | Select-Object -ExpandProperty ProcessId'],
capture_output=True, text=True
)
pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()]
for pid in pids:
subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True)
print(f'잔존 프로세스 종료: {pid}')
if not pids:
print('실행 중인 봇 없음 — 새로 시작합니다')
+9
View File
@@ -0,0 +1,9 @@
import asyncio, sys, os
sys.path.insert(0, '.')
from app.main import load_env; load_env()
from app.monitor.notifier import send
mode = os.getenv('KIS_MOCK', 'true')
dry = os.getenv('DRY_RUN', 'true')
label = '[모의투자]' if mode == 'true' else '[실거래]'
asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})'))
print('Discord 알림 전송 완료')
+9
View File
@@ -0,0 +1,9 @@
import asyncio, sys, os
sys.path.insert(0, '.')
from app.main import load_env; load_env()
from app.monitor.notifier import send
mode = os.getenv('KIS_MOCK', 'true')
dry = os.getenv('DRY_RUN', 'true')
label = '[모의투자]' if mode == 'true' else '[실거래]'
asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})'))
print('Discord 알림 전송 완료')
+113
View File
@@ -0,0 +1,113 @@
"""
워치독 태스크를 XML로 직접 등록
관리자 권한 없이 현재 사용자 세션으로 등록
"""
import subprocess, sys, os, tempfile
PROJECT = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3'
SCRIPT = fr'{PROJECT}\scripts\run_watchdog.ps1'
xml = f'''<?xml version="1.0" encoding="UTF-16"?>
<Task version="1.3" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
<RegistrationInfo>
<URI>\\StockBot\\StockBot_Watchdog</URI>
</RegistrationInfo>
<Principals>
<Principal id="Author">
<LogonType>InteractiveToken</LogonType>
<RunLevel>LeastPrivilege</RunLevel>
</Principal>
</Principals>
<Settings>
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
<ExecutionTimeLimit>PT3M</ExecutionTimeLimit>
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
<StartWhenAvailable>true</StartWhenAvailable>
<IdleSettings>
<Duration>PT10M</Duration>
<WaitTimeout>PT1H</WaitTimeout>
<StopOnIdleEnd>true</StopOnIdleEnd>
<RestartOnIdle>false</RestartOnIdle>
</IdleSettings>
</Settings>
<Triggers>
<CalendarTrigger>
<StartBoundary>2026-05-27T09:00:00+09:00</StartBoundary>
<Repetition>
<Interval>PT5M</Interval>
<Duration>PT6H10M</Duration>
<StopAtDurationEnd>true</StopAtDurationEnd>
</Repetition>
<ScheduleByWeek>
<WeeksInterval>1</WeeksInterval>
<DaysOfWeek>
<Monday />
<Tuesday />
<Wednesday />
<Thursday />
<Friday />
</DaysOfWeek>
</ScheduleByWeek>
</CalendarTrigger>
</Triggers>
<Actions Context="Author">
<Exec>
<Command>powershell.exe</Command>
<Arguments>-NonInteractive -ExecutionPolicy Bypass -File "{SCRIPT}"</Arguments>
<WorkingDirectory>{PROJECT}</WorkingDirectory>
</Exec>
</Actions>
</Task>'''
# UTF-16 LE BOM으로 임시 파일 저장 (Task Scheduler XML 요구사항)
with tempfile.NamedTemporaryFile(suffix='.xml', delete=False, mode='wb') as f:
f.write(b'\xff\xfe') # UTF-16 LE BOM
f.write(xml.encode('utf-16-le'))
tmp = f.name
try:
# 1단계: XML로 등록
ps_cmd = f'Register-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\\\\StockBot\\\\" -Xml (Get-Content -Path \'{tmp}\' -Raw -Encoding Unicode) -Force | Out-Null; Write-Host "1단계 완료"'
r = subprocess.run(
['powershell', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps_cmd],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
print(r.stdout.strip())
# 2단계: 한글 경로 깨짐 교정 (setup_scheduler.ps1과 동일 방식)
# 등록된 태스크의 Arguments에서 garbled 문자열 추출 → 정상 한글로 치환 후 재등록
fix_cmd = r'''
$task = Get-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\"
$stored = $task.Actions[0].Arguments
if ($stored -match [regex]::Escape("바탕 화면")) {
Write-Host "경로 정상"
} else {
$xml = Export-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\"
if ($stored -match 'OneDrive\\(.+?)\\stockbot') {
$garbled = $Matches[1]
$fixedXml = $xml.Replace($garbled, "바탕 화면")
Register-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\" -Xml $fixedXml -Force | Out-Null
Write-Host "한글 경로 교정 완료"
} else {
Write-Host "교정 패턴 미일치 — 수동 확인 필요"
}
}
'''
r2 = subprocess.run(
['powershell', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', fix_cmd],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
print(r2.stdout.strip())
if r2.returncode != 0:
print('STDERR:', r2.stderr.strip())
# 최종 확인
check = subprocess.run(
['powershell', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command',
'Get-ScheduledTask -TaskPath "\\StockBot\\" | Format-Table TaskName, State -AutoSize'],
capture_output=True, text=True, encoding='utf-8', errors='replace'
)
print(check.stdout)
finally:
os.unlink(tmp)
+17
View File
@@ -0,0 +1,17 @@
import asyncio, json, sys
sys.path.insert(0, '.')
from app.main import load_env; load_env()
from app.monitor.notifier import send
ctx = json.load(open('data/midday_context.json', encoding='utf-8'))
hot = ', '.join(ctx.get('hot_sectors', [])) or '없음'
avoid = ', '.join(ctx.get('avoid_sectors', [])) or '없음'
flag = '✅ 점심진입허용' if ctx.get('lunch_trade_allowed', True) else '🚫 점심진입중단'
msg = (
f'[장중분석] {ctx["date"]} {ctx.get("generated_at","")}\n'
f'{flag} | 포지션배율: x{ctx.get("position_size_multiplier", 1.0)}\n'
f'주목: {hot} | 회피: {avoid}\n'
f'📝 {ctx.get("reason","")}'
)
asyncio.run(send(msg))
print('Discord 전송 완료')
+54
View File
@@ -0,0 +1,54 @@
import subprocess, os, sys, asyncio, time
BASE = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3'
pid_file = os.path.join(BASE, 'logs', 'bot.pid')
os.chdir(BASE)
sys.path.insert(0, BASE)
# 토큰 발급 가능한지 테스트 (최대 3분 재시도)
from app.main import load_env
load_env()
max_wait = 180
interval = 15
elapsed = 0
while elapsed < max_wait:
try:
import httpx
from app.execution.kis_client import KISClient
client = KISClient()
asyncio.run(client.get_access_token())
print(f"토큰 발급 성공 ({elapsed}초 경과)")
break
except RuntimeError as e:
if "EGW00133" in str(e) or "잠시 후" in str(e):
print(f"토큰 대기 중... ({elapsed}초, {interval}초 후 재시도)")
time.sleep(interval)
elapsed += interval
else:
print(f"토큰 오류 (재시도 불가): {e}")
sys.exit(1)
else:
print("토큰 발급 타임아웃")
sys.exit(1)
# 봇 시작
log_path = os.path.join(BASE, "logs", "bot_stderr.log")
proc = subprocess.Popen(
[sys.executable, "app/main.py"],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=open(log_path, "a", encoding="utf-8"),
stderr=subprocess.STDOUT,
close_fds=True,
)
with open(pid_file, "w") as f:
f.write(str(proc.pid))
print(f"봇 시작 완료 PID={proc.pid}")
from app.monitor.notifier import send
import os as _os
mode = _os.getenv("KIS_MOCK", "true")
dry = _os.getenv("DRY_RUN", "true")
label = "[모의투자]" if mode == "true" else "[실거래]"
asyncio.run(send(f"{label} 자동매매 봇 시작 (DRY_RUN={dry})"))
print("Discord 알림 전송 완료")
+52
View File
@@ -0,0 +1,52 @@
import subprocess, os, sys, asyncio
pid_file = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3\logs\bot.pid'
project_dir = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3'
# 1-a) PID 파일로 종료
if os.path.exists(pid_file):
try:
pid = int(open(pid_file).read().strip())
subprocess.run(['taskkill', '/PID', str(pid), '/F'], capture_output=True)
print(f'PID 파일 종료: {pid}')
except Exception as e:
print(f'PID 파일 종료 실패: {e}')
os.remove(pid_file)
# 1-b) Get-CimInstance로 잔존 프로세스 스캔
r = subprocess.run(
['powershell', '-Command',
'Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like "*app/main.py*" -or $_.CommandLine -like "*app\\main.py*" } | Select-Object -ExpandProperty ProcessId'],
capture_output=True, text=True
)
pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()]
for pid in pids:
subprocess.run(['taskkill', '/PID', pid, '/F'], capture_output=True)
print(f'잔존 프로세스 종료: {pid}')
if not pids:
print('실행 중인 봇 없음 — 새로 시작합니다')
# 2. 봇 시작
os.chdir(project_dir)
proc = subprocess.Popen(
[sys.executable, 'app/main.py'],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=open('logs/bot_stderr.log', 'a', encoding='utf-8'),
stderr=subprocess.STDOUT,
close_fds=True,
)
with open('logs/bot.pid', 'w') as f:
f.write(str(proc.pid))
print(f'봇 시작 완료 PID={proc.pid}')
# 3. Discord 알림
sys.path.insert(0, '.')
from app.main import load_env
load_env()
from app.monitor.notifier import send
mode = os.getenv('KIS_MOCK', 'true')
dry = os.getenv('DRY_RUN', 'true')
label = '[모의투자]' if mode == 'true' else '[실거래]'
asyncio.run(send(f'{label} 자동매매 봇 시작 (DRY_RUN={dry})'))
print('Discord 알림 전송 완료')
+14
View File
@@ -0,0 +1,14 @@
import subprocess, sys, os
os.chdir(r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3')
proc = subprocess.Popen(
[sys.executable, 'app/main.py'],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=open('logs/bot_stderr.log', 'a', encoding='utf-8'),
stderr=subprocess.STDOUT,
close_fds=True,
)
# PID 파일 저장 (BOM 없이 저장)
with open('logs/bot.pid', 'w', encoding='utf-8', newline='') as f:
f.write(str(proc.pid))
print(f'봇 시작 완료 PID={proc.pid}')
+58
View File
@@ -0,0 +1,58 @@
import subprocess, os, sys, asyncio
BASE = r'C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3'
pid_file = os.path.join(BASE, 'logs', 'bot.pid')
# 1-a) PID 파일로 종료
if os.path.exists(pid_file):
try:
pid = int(open(pid_file).read().strip())
result = subprocess.run(["taskkill", "/PID", str(pid), "/F"], capture_output=True, text=True)
print(f"PID 파일 종료: {pid} -> returncode={result.returncode}")
except Exception as e:
print(f"PID 파일 종료 실패: {e}")
os.remove(pid_file)
else:
print("PID 파일 없음")
# 1-b) 잔존 프로세스 스캔
r = subprocess.run(
["powershell", "-Command",
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*app/main.py*' -or $_.CommandLine -like '*app\\\\main.py*' } | Select-Object -ExpandProperty ProcessId"],
capture_output=True, text=True
)
pids = [p.strip() for p in r.stdout.strip().splitlines() if p.strip().isdigit()]
for pid in pids:
result = subprocess.run(["taskkill", "/PID", pid, "/F"], capture_output=True, text=True)
print(f"잔존 프로세스 종료: {pid} -> returncode={result.returncode}")
if not pids:
print("실행 중인 봇 없음 — 새로 시작합니다")
# 2. 봇 시작 + PID 저장
os.chdir(BASE)
log_path = os.path.join(BASE, "logs", "bot_stderr.log")
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
proc = subprocess.Popen(
[sys.executable, "-u", "app/main.py"],
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=open(log_path, "a", encoding="utf-8"),
stderr=subprocess.STDOUT,
close_fds=True,
env=env,
)
with open(pid_file, "w") as f:
f.write(str(proc.pid))
print(f"봇 시작 완료 PID={proc.pid}")
# 3. Discord 알림
sys.path.insert(0, BASE)
from app.main import load_env
load_env()
from app.monitor.notifier import send
mode = os.getenv("KIS_MOCK", "true")
dry = os.getenv("DRY_RUN", "true")
label = "[모의투자]" if mode == "true" else "[실거래]"
asyncio.run(send(f"{label} 자동매매 봇 시작 (DRY_RUN={dry})"))
print("Discord 알림 전송 완료")
+15
View File
@@ -0,0 +1,15 @@
import sys, asyncio
sys.path.insert(0, '.')
from app.main import load_env, StockBot
load_env()
async def test():
bot = StockBot()
print("StockBot 생성 완료")
await bot.initialize()
print("initialize() 완료")
print("5초 슬립...")
await asyncio.sleep(5)
print("5초 완료 - 정상 실행 중")
asyncio.run(test())
+170
View File
@@ -0,0 +1,170 @@
"""
Build training rows from external minute bars.
This generates synthetic candidate rows from minute bars, not actual bot trades.
Rows are useful for pretraining movement/holding-period models.
"""
import argparse
import csv
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
MINUTE_ROOT = ROOT / "data" / "external" / "minute"
DAILY_ROOT = ROOT / "data" / "external" / "daily"
DEFAULT_OUT = ROOT / "data" / "external_training_dataset.csv"
CHECKPOINTS = (1, 3, 5, 10)
def _read_csv(path: Path) -> list[dict]:
with path.open("r", encoding="utf-8-sig", newline="") as f:
return list(csv.DictReader(f))
def _num(value, default=0.0):
try:
return float(str(value).replace(",", ""))
except (TypeError, ValueError):
return default
def _load_daily_amounts() -> dict[tuple[str, str], dict]:
result = {}
for file in DAILY_ROOT.glob("*/stocks.csv"):
rows = _read_csv(file)
for row in rows:
date = str(row.get("date") or file.parent.name)
ticker = str(row.get("ticker") or row.get("티커") or "")
if ticker:
result[(date, ticker)] = row
return result
def _future_metrics(rows: list[dict], idx: int, entry_price: float):
metrics = {}
highs = []
lows = []
for minutes in CHECKPOINTS:
j = idx + minutes
if j >= len(rows):
metrics[f"price_{minutes}m"] = ""
metrics[f"ret_{minutes}m"] = ""
metrics[f"mfe_{minutes}m"] = ""
metrics[f"mae_{minutes}m"] = ""
continue
window = rows[idx + 1:j + 1]
highs.extend(_num(r["high"]) for r in window)
lows.extend(_num(r["low"]) for r in window)
close = _num(rows[j]["close"])
high = max(highs) if highs else entry_price
low = min(lows) if lows else entry_price
metrics[f"price_{minutes}m"] = close
metrics[f"ret_{minutes}m"] = (close - entry_price) / entry_price * 100 if entry_price else 0
metrics[f"mfe_{minutes}m"] = (high - entry_price) / entry_price * 100 if entry_price else 0
metrics[f"mae_{minutes}m"] = (low - entry_price) / entry_price * 100 if entry_price else 0
return metrics
def _rows_for_file(path: Path, daily: dict, k: float, breakout_only: bool):
rows = _read_csv(path)
rows = [r for r in rows if r.get("time") and _num(r.get("close")) > 0]
rows.sort(key=lambda r: (r.get("date", ""), r.get("time", "")))
if len(rows) < 20:
return []
by_date = defaultdict(list)
for row in rows:
by_date[row["date"]].append(row)
out = []
prev_by_date = {}
for date in sorted(by_date):
day_rows = by_date[date]
ticker = day_rows[0]["ticker"]
daily_row = daily.get((date, ticker), {})
prev_high = _num(daily_row.get("high"))
prev_low = _num(daily_row.get("low"))
prev_amount = _num(daily_row.get("amount"))
if not prev_high or not prev_low:
prev = prev_by_date.get(ticker)
if prev:
prev_high = prev["high"]
prev_low = prev["low"]
prev_amount = prev["amount"]
today_open = _num(day_rows[0]["open"]) or _num(day_rows[0]["close"])
target = today_open + (prev_high - prev_low) * k if prev_high and prev_low else 0
crossed = False
for idx, row in enumerate(day_rows[:-max(CHECKPOINTS)]):
tm = row["time"][:4]
if tm < "0905" or tm >= "1400":
continue
close = _num(row["close"])
if breakout_only:
if not target or close < target or crossed:
continue
crossed = True
metrics = _future_metrics(day_rows, idx, close)
label_win = 1 if _num(metrics.get("ret_10m")) > 0 else 0
label_stop_loss = 1 if _num(metrics.get("mae_10m")) <= -2.0 else 0
out.append({
"source": "external_minute",
"date": date,
"ticker": ticker,
"entry_time": row["time"],
"current_price": close,
"entry_price": close,
"target_price": target,
"today_open": today_open,
"prev_high": prev_high,
"prev_low": prev_low,
"prev_amount": prev_amount,
"volume": row.get("volume", ""),
**metrics,
"label_win": label_win,
"label_stop_loss": label_stop_loss,
})
prev_by_date[ticker] = {
"high": max(_num(r["high"]) for r in day_rows),
"low": min(_num(r["low"]) for r in day_rows),
"amount": sum(_num(r["close"]) * _num(r["volume"]) for r in day_rows),
}
return out
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--minute-root", default=str(MINUTE_ROOT))
parser.add_argument("--out", default=str(DEFAULT_OUT))
parser.add_argument("--k", type=float, default=0.5)
parser.add_argument("--all-minutes", action="store_true", help="Use every eligible minute, not only first breakout.")
args = parser.parse_args()
daily = _load_daily_amounts()
all_rows = []
for path in Path(args.minute_root).glob("*/*.csv"):
all_rows.extend(_rows_for_file(path, daily, args.k, breakout_only=not args.all_minutes))
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = ROOT / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
fieldnames = sorted({key for row in all_rows for key in row.keys()})
with out_path.open("w", encoding="utf-8-sig", newline="") as f:
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(all_rows)
print(f"external dataset rows={len(all_rows)} -> {out_path}")
if __name__ == "__main__":
main()
+84
View File
@@ -0,0 +1,84 @@
"""
Collect daily Korean-market features for model training.
Outputs:
data/external/daily/YYYYMMDD/stocks.csv
data/external/daily/YYYYMMDD/indexes.csv
"""
import argparse
import sys
from datetime import datetime
from pathlib import Path
import pandas as pd
ROOT = Path(__file__).resolve().parent.parent
OUT_ROOT = ROOT / "data" / "external" / "daily"
def _yyyymmdd(date_text: str | None) -> str:
if date_text:
return date_text.replace("-", "")
return datetime.now().strftime("%Y%m%d")
def _standardize_stock_ohlcv(df: pd.DataFrame, date_yyyymmdd: str) -> pd.DataFrame:
df = df.reset_index()
columns = list(df.columns)
rename = {columns[0]: "ticker"}
standard = ["open", "high", "low", "close", "volume", "amount", "change_pct"]
for source, target in zip(columns[1:], standard):
rename[source] = target
df = df.rename(columns=rename)
df.insert(0, "date", date_yyyymmdd)
return df[[c for c in ["date", "ticker", *standard] if c in df.columns]]
def _standardize_index_row(row: dict, date_yyyymmdd: str, code: str, name: str) -> dict:
values = list(row.values())
keys = ["open", "high", "low", "close", "volume", "amount", "change_pct"]
out = {"date": date_yyyymmdd, "code": code, "name": name}
for key, value in zip(keys, values):
out[key] = value
return out
def collect_with_pykrx(date_yyyymmdd: str, out_dir: Path):
try:
from pykrx import stock
except ImportError as exc:
raise RuntimeError("pykrx is not installed. Install requirements first.") from exc
stocks_raw = stock.get_market_ohlcv_by_ticker(date_yyyymmdd, market="ALL")
stocks = _standardize_stock_ohlcv(stocks_raw, date_yyyymmdd)
stocks.to_csv(out_dir / "stocks.csv", index=False, encoding="utf-8-sig")
index_rows = []
for code, name in (("1001", "KOSPI"), ("2001", "KOSDAQ")):
try:
df = stock.get_index_ohlcv_by_date(date_yyyymmdd, date_yyyymmdd, code)
if not df.empty:
index_rows.append(_standardize_index_row(df.iloc[-1].to_dict(), date_yyyymmdd, code, name))
except Exception as exc:
print(f"index fetch failed {name}: {exc}", file=sys.stderr)
pd.DataFrame(index_rows).to_csv(out_dir / "indexes.csv", index=False, encoding="utf-8-sig")
return len(stocks), len(index_rows)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--date", help="YYYY-MM-DD or YYYYMMDD. Defaults to today.")
args = parser.parse_args()
date_yyyymmdd = _yyyymmdd(args.date)
out_dir = OUT_ROOT / date_yyyymmdd
out_dir.mkdir(parents=True, exist_ok=True)
stock_count, index_count = collect_with_pykrx(date_yyyymmdd, out_dir)
print(f"saved daily features: stocks={stock_count}, indexes={index_count}, dir={out_dir}")
if __name__ == "__main__":
main()
+102
View File
@@ -0,0 +1,102 @@
"""
Collect KIS intraday minute bars for selected tickers.
Usage:
python scripts/collect_minute_data.py --tickers 005930,000660
python scripts/collect_minute_data.py --top 30
"""
import argparse
import asyncio
import csv
import json
import os
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.main import load_env
from app.execution.kis_client import KISClient
OUT_ROOT = ROOT / "data" / "external" / "minute"
def _date_dir(date_text: str | None) -> str:
if date_text:
return date_text.replace("-", "")
return datetime.now().strftime("%Y%m%d")
def _load_cached_tickers(limit: int) -> list[str]:
cache = ROOT / "data" / "universe_cache.json"
if not cache.exists():
return []
data = json.loads(cache.read_text(encoding="utf-8"))
return list(data.get("tickers", []))[:limit]
async def _resolve_tickers(kis: KISClient, args) -> list[str]:
if args.tickers:
return [t.strip() for t in args.tickers.split(",") if t.strip()]
cached = _load_cached_tickers(args.top)
if cached:
return cached
rank = await kis.get_volume_rank(top_n=args.top)
return [r["ticker"] for r in rank]
def _write_csv(path: Path, rows: list[dict]):
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["date", "time", "ticker", "open", "high", "low", "close", "volume"])
writer.writeheader()
writer.writerows(rows)
async def main_async(args):
load_env()
if args.real_quotes:
os.environ["KIS_MOCK"] = "false"
kis = KISClient()
await kis.get_access_token()
tickers = await _resolve_tickers(kis, args)
out_dir = OUT_ROOT / _date_dir(args.date)
saved = 0
for ticker in tickers:
try:
rows = await kis.get_ohlcv_minute(ticker, hour=args.hour)
if rows:
_write_csv(out_dir / f"{ticker}.csv", rows)
saved += 1
print(f"saved {ticker}: {len(rows)} rows")
else:
print(f"no rows {ticker}")
except Exception as exc:
print(f"failed {ticker}: {exc}", file=sys.stderr)
await asyncio.sleep(args.sleep)
print(f"minute collection done: saved={saved}/{len(tickers)}, dir={out_dir}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--date", help="YYYY-MM-DD or YYYYMMDD. Defaults to today.")
parser.add_argument("--tickers", help="Comma-separated tickers.")
parser.add_argument("--top", type=int, default=30)
parser.add_argument("--hour", default="153000", help="KIS upper-bound time HHMMSS.")
parser.add_argument("--sleep", type=float, default=1.1)
parser.add_argument("--real-quotes", action="store_true", help="Use real quote API even if .env is mock.")
args = parser.parse_args()
asyncio.run(main_async(args))
if __name__ == "__main__":
main()
+13
View File
@@ -0,0 +1,13 @@
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $Root
$Python = "python"
$Requirements = Join-Path $Root "requirements.txt"
$Wheelhouse = Join-Path $Root "vendor\wheels"
New-Item -ItemType Directory -Force -Path $Wheelhouse | Out-Null
Write-Host "[download] saving packages to $Wheelhouse"
& $Python -m pip download -r $Requirements -d $Wheelhouse
+128
View File
@@ -0,0 +1,128 @@
"""
Export entry snapshots joined with trade outcomes for later model training.
Usage:
python scripts/export_training_dataset.py
python scripts/export_training_dataset.py data/training_dataset.csv
"""
import csv
import os
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.db.models import get_conn, init_db
DEFAULT_OUT = ROOT / "data" / "training_dataset.csv"
def export_csv(out_path: Path):
init_db()
out_path.parent.mkdir(parents=True, exist_ok=True)
query = """
WITH post AS (
SELECT
trade_id,
MAX(CASE WHEN elapsed_sec = 60 THEN current_price END) AS price_1m,
MAX(CASE WHEN elapsed_sec = 60 THEN return_pct END) AS ret_1m,
MAX(CASE WHEN elapsed_sec = 60 THEN mfe_pct END) AS mfe_1m,
MAX(CASE WHEN elapsed_sec = 60 THEN mae_pct END) AS mae_1m,
MAX(CASE WHEN elapsed_sec = 180 THEN current_price END) AS price_3m,
MAX(CASE WHEN elapsed_sec = 180 THEN return_pct END) AS ret_3m,
MAX(CASE WHEN elapsed_sec = 180 THEN mfe_pct END) AS mfe_3m,
MAX(CASE WHEN elapsed_sec = 180 THEN mae_pct END) AS mae_3m,
MAX(CASE WHEN elapsed_sec = 300 THEN current_price END) AS price_5m,
MAX(CASE WHEN elapsed_sec = 300 THEN return_pct END) AS ret_5m,
MAX(CASE WHEN elapsed_sec = 300 THEN mfe_pct END) AS mfe_5m,
MAX(CASE WHEN elapsed_sec = 300 THEN mae_pct END) AS mae_5m,
MAX(CASE WHEN elapsed_sec = 600 THEN current_price END) AS price_10m,
MAX(CASE WHEN elapsed_sec = 600 THEN return_pct END) AS ret_10m,
MAX(CASE WHEN elapsed_sec = 600 THEN mfe_pct END) AS mfe_10m,
MAX(CASE WHEN elapsed_sec = 600 THEN mae_pct END) AS mae_10m
FROM post_entry_snapshots
GROUP BY trade_id
)
SELECT
s.trade_id,
s.date,
s.ticker,
s.name,
s.entry_time,
s.current_price,
s.entry_price,
s.target_price,
s.stop_price,
s.today_open,
s.prev_high,
s.prev_low,
s.prev_amount,
s.volume,
s.change_pct,
s.market_sentiment,
s.sentiment_score,
s.risk_level,
s.trade_allowed,
s.hot_sectors,
s.avoid_sectors,
s.boosted_tickers,
s.blacklist_tickers,
s.ai_boosted,
s.ai_win_score,
s.ai_stop_loss_score,
s.ai_model_version,
s.position_size_multiplier,
s.combined_multiplier,
s.entry_reason,
post.price_1m,
post.ret_1m,
post.mfe_1m,
post.mae_1m,
post.price_3m,
post.ret_3m,
post.mfe_3m,
post.mae_3m,
post.price_5m,
post.ret_5m,
post.mfe_5m,
post.mae_5m,
post.price_10m,
post.ret_10m,
post.mfe_10m,
post.mae_10m,
t.exit_time,
t.exit_price,
t.quantity,
t.exit_reason,
t.pnl,
CASE WHEN t.pnl > 0 THEN 1 ELSE 0 END AS label_win,
CASE WHEN t.exit_reason = 'SL' THEN 1 ELSE 0 END AS label_stop_loss
FROM entry_snapshots s
LEFT JOIN trades t ON t.id = s.trade_id
LEFT JOIN post ON post.trade_id = s.trade_id
ORDER BY s.date, s.entry_time, s.ticker
"""
with get_conn() as conn:
conn.row_factory = None
cur = conn.execute(query)
headers = [d[0] for d in cur.description]
rows = cur.fetchall()
with out_path.open("w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(headers)
writer.writerows(rows)
print(f"exported {len(rows)} rows -> {out_path}")
if __name__ == "__main__":
target = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_OUT
if not target.is_absolute():
target = ROOT / target
export_csv(target)
+25
View File
@@ -0,0 +1,25 @@
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $Root
. "$Root\scripts\stockbot_env.ps1"
$Python = Resolve-StockBotPython -Project $Root
$Requirements = Join-Path $Root "requirements.txt"
$Wheelhouse = Join-Path $Root "vendor\wheels"
if (-not (Test-Path $Requirements)) {
throw "requirements.txt not found: $Requirements"
}
if (Test-Path $Wheelhouse) {
$WheelFiles = Get-ChildItem -Path $Wheelhouse -Filter "*.whl" -ErrorAction SilentlyContinue
if ($WheelFiles.Count -gt 0) {
Write-Host "[install] using local wheelhouse: $Wheelhouse"
& $Python -m pip install --no-index --find-links $Wheelhouse -r $Requirements
exit $LASTEXITCODE
}
}
Write-Host "[install] using online package index"
& $Python -m pip install -r $Requirements
+192
View File
@@ -0,0 +1,192 @@
param(
[switch]$SkipWinget,
[switch]$SkipCliInstall,
[switch]$SkipScheduler,
[switch]$OnlinePip
)
$ErrorActionPreference = "Stop"
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONUTF8 = "1"
$env:PYTHONIOENCODING = "utf-8"
$Project = Split-Path -Parent $PSScriptRoot
$VenvPython = Join-Path $Project ".venv\Scripts\python.exe"
$Requirements = Join-Path $Project "requirements.txt"
$Wheelhouse = Join-Path $Project "vendor\wheels"
function Write-Step {
param([string]$Message)
Write-Host ""
Write-Host "== $Message ==" -ForegroundColor Cyan
}
function Test-Admin {
$Identity = [Security.Principal.WindowsIdentity]::GetCurrent()
$Principal = New-Object Security.Principal.WindowsPrincipal($Identity)
return $Principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}
function Ensure-Directory {
param([string]$Path)
New-Item -ItemType Directory -Force -Path $Path | Out-Null
}
function Install-WingetPackage {
param(
[string]$Id,
[string]$CommandName
)
if ($SkipWinget) {
Write-Host "[skip] winget install disabled for $Id"
return
}
if ($CommandName -and (Get-Command $CommandName -ErrorAction SilentlyContinue)) {
Write-Host "[ok] $CommandName already available"
return
}
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Write-Host "[warn] winget not found. Install $Id manually if this step is needed." -ForegroundColor Yellow
return
}
Write-Host "[install] $Id"
winget install --id $Id -e --accept-package-agreements --accept-source-agreements
Refresh-ProcessPath
}
function Refresh-ProcessPath {
$MachinePath = [Environment]::GetEnvironmentVariable("Path", "Machine")
$UserPath = [Environment]::GetEnvironmentVariable("Path", "User")
$env:Path = "$MachinePath;$UserPath"
}
function Resolve-BasePython {
Refresh-ProcessPath
if (Get-Command py -ErrorAction SilentlyContinue) {
$Path = (& py -3.11 -c "import sys; print(sys.executable)" 2>$null)
if ($LASTEXITCODE -eq 0 -and $Path) {
return $Path.Trim()
}
}
if (Get-Command python -ErrorAction SilentlyContinue) {
$Version = (& python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>$null)
if ($LASTEXITCODE -eq 0 -and $Version.Trim() -eq "3.11") {
$Path = (& python -c "import sys; print(sys.executable)")
return $Path.Trim()
}
}
$KnownPaths = @(
(Join-Path $env:LOCALAPPDATA "Programs\Python\Python311\python.exe"),
"C:\Program Files\Python311\python.exe",
"C:\Program Files (x86)\Python311\python.exe"
)
foreach ($KnownPath in $KnownPaths) {
if (Test-Path $KnownPath) {
return $KnownPath
}
}
throw "Python 3.11 was not found. Re-run without -SkipWinget, or install Python 3.11 manually."
}
function Install-NodeCli {
if ($SkipCliInstall) {
Write-Host "[skip] CLI install disabled"
return
}
Refresh-ProcessPath
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Host "[warn] npm not found. Claude/Codex CLI install skipped." -ForegroundColor Yellow
return
}
$HasClaude = (Get-Command claude.cmd -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:APPDATA "npm\claude.cmd"))
$HasCodex = (Get-Command codex.cmd -ErrorAction SilentlyContinue) -or (Test-Path (Join-Path $env:APPDATA "npm\codex.cmd"))
if (-not $HasClaude) {
Write-Host "[install] Claude Code CLI"
npm install -g @anthropic-ai/claude-code
} else {
Write-Host "[ok] Claude CLI already available"
}
if (-not $HasCodex) {
Write-Host "[install] Codex CLI"
npm install -g @openai/codex
} else {
Write-Host "[ok] Codex CLI already available"
}
}
if (-not (Test-Admin)) {
Write-Host "[warn] Not running as Administrator. Scheduler registration may fail." -ForegroundColor Yellow
}
Set-Location $Project
Write-Step "Project folders"
Ensure-Directory (Join-Path $Project "logs")
Ensure-Directory (Join-Path $Project "data")
Ensure-Directory (Join-Path $Project "models")
Ensure-Directory (Join-Path $Project "reports\daily")
Ensure-Directory (Join-Path $Project "reports\proposals")
if (-not (Test-Path (Join-Path $Project ".env"))) {
Write-Host "[warn] .env not found. Restore it from your backup before live API use." -ForegroundColor Yellow
}
Write-Step "System tools"
Refresh-ProcessPath
Install-WingetPackage -Id "Git.Git" -CommandName "git"
Install-WingetPackage -Id "OpenJS.NodeJS.LTS" -CommandName "node"
Install-WingetPackage -Id "Python.Python.3.11" -CommandName $null
Write-Step "Python virtual environment"
$BasePython = Resolve-BasePython
Write-Host "[ok] Python base: $BasePython"
if (-not (Test-Path $VenvPython)) {
& $BasePython -m venv (Join-Path $Project ".venv")
}
& $VenvPython -m ensurepip --upgrade
Write-Step "Python libraries"
if (-not (Test-Path $Requirements)) {
throw "requirements.txt not found: $Requirements"
}
if ((Test-Path $Wheelhouse) -and -not $OnlinePip) {
Write-Host "[install] using local wheelhouse: $Wheelhouse"
& $VenvPython -m pip install --no-index --find-links $Wheelhouse -r $Requirements
} else {
Write-Host "[install] using online package index"
& $VenvPython -m pip install -r $Requirements
}
Write-Step "Claude/Codex CLI"
Install-NodeCli
Write-Step "Sanity checks"
& $VenvPython -c "import aiohttp, pandas, sklearn, joblib, holidays; print('python deps ok')"
if (-not $SkipScheduler) {
Write-Step "Windows Task Scheduler"
& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $Project "scripts\setup_scheduler.ps1")
} else {
Write-Host "[skip] scheduler registration disabled"
}
Write-Step "Done"
Write-Host "Restore complete. Reboot once if newly installed winget packages are not visible in new terminals." -ForegroundColor Green
+7 -5
View File
@@ -1,6 +1,8 @@
@echo off
REM 매매 봇 실행 스크립트
REM 작업 스케줄러에서 07:55에 실행 (평일)
cd /d "C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3"
python app\main.py
setlocal
cd /d "%~dp0.."
if exist ".venv\Scripts\python.exe" (
".venv\Scripts\python.exe" app\main.py
) else (
python app\main.py
)
+3 -2
View File
@@ -7,8 +7,9 @@ $env:PYTHONIOENCODING = "utf-8"
$PROJECT = Split-Path -Parent $PSScriptRoot
$LOG = "$PROJECT\logs\bot_start.log"
$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd"
$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe"
. "$PROJECT\scripts\stockbot_env.ps1"
$CLAUDE = Resolve-StockBotClaude
$PYTHON = Resolve-StockBotPython -Project $PROJECT
$utf8 = New-Object System.Text.UTF8Encoding $false
Set-Location $PROJECT
+3 -2
View File
@@ -9,8 +9,9 @@ $env:PYTHONIOENCODING = "utf-8"
$PROJECT = Split-Path -Parent $PSScriptRoot
$LOG = "$PROJECT\logs\evening.log"
$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd"
$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe"
. "$PROJECT\scripts\stockbot_env.ps1"
$CLAUDE = Resolve-StockBotClaude
$PYTHON = Resolve-StockBotPython -Project $PROJECT
$utf8 = New-Object System.Text.UTF8Encoding $false
Set-Location $PROJECT
+3 -2
View File
@@ -9,8 +9,9 @@ $env:PYTHONIOENCODING = "utf-8"
$PROJECT = Split-Path -Parent $PSScriptRoot
$LOG = "$PROJECT\logs\midday.log"
$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd"
$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe"
. "$PROJECT\scripts\stockbot_env.ps1"
$CLAUDE = Resolve-StockBotClaude
$PYTHON = Resolve-StockBotPython -Project $PROJECT
$utf8 = New-Object System.Text.UTF8Encoding $false
Set-Location $PROJECT
+3 -2
View File
@@ -9,8 +9,9 @@ $env:PYTHONIOENCODING = "utf-8"
$PROJECT = Split-Path -Parent $PSScriptRoot
$LOG = "$PROJECT\logs\morning.log"
$CLAUDE = "C:\Users\whdwo\AppData\Roaming\npm\claude.cmd"
$PYTHON = "C:\Users\whdwo\.pyenv\pyenv-win\versions\3.11.9\python.exe"
. "$PROJECT\scripts\stockbot_env.ps1"
$CLAUDE = Resolve-StockBotClaude
$PYTHON = Resolve-StockBotPython -Project $PROJECT
$utf8 = New-Object System.Text.UTF8Encoding $false
Set-Location $PROJECT
+56
View File
@@ -0,0 +1,56 @@
$ErrorActionPreference = "Stop"
$Root = Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path)
Set-Location $Root
. "$Root\scripts\stockbot_env.ps1"
$Python = Resolve-StockBotPython -Project $Root
$LogDir = Join-Path $Root "logs"
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
$LogPath = Join-Path $LogDir "training.log"
function Write-Log {
param([string]$Message)
$Line = "[{0}] {1}" -f (Get-Date -Format "yyyy-MM-dd HH:mm:ss"), $Message
Add-Content -Path $LogPath -Value $Line -Encoding UTF8
Write-Host $Line
}
function Invoke-PythonStep {
param(
[string]$Name,
[string[]]$Args,
[bool]$Required = $true
)
Write-Log $Name
& $Python @Args *>> $LogPath
$Code = $LASTEXITCODE
if ($Code -ne 0) {
$Message = "$Name failed with exit code $Code"
if ($Required) {
throw $Message
}
Write-Log "warning: $Message"
}
}
Write-Log "training pipeline started"
$HolidayCheck = & $Python scripts\_is_trading_day.py 2>&1
if ($LASTEXITCODE -ne 0) {
Write-Log "market closed - skipped ($HolidayCheck)"
exit 0
}
Invoke-PythonStep -Name "collecting daily market features" -Args @("scripts\collect_daily_features.py") -Required $false
Invoke-PythonStep -Name "collecting KIS minute data" -Args @("scripts\collect_minute_data.py", "--top", "30", "--real-quotes") -Required $false
Invoke-PythonStep -Name "exporting bot training dataset" -Args @("scripts\export_training_dataset.py", "data\training_dataset.csv") -Required $true
Invoke-PythonStep -Name "building external training dataset" -Args @("scripts\build_external_training_dataset.py", "--out", "data\external_training_dataset.csv") -Required $true
Invoke-PythonStep -Name "training model" -Args @("scripts\train_ai_model.py") -Required $true
Write-Log "training pipeline finished"
+28 -10
View File
@@ -1,21 +1,39 @@
# 워치독 스크립트 — 봇 생존 감시 + 자동 재시작
# 작업 스케줄러에서 5분마다 실행 (평일 09:00~15:10)
$ErrorActionPreference = "Stop"
$OutputEncoding = [System.Text.Encoding]::UTF8
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$env:PYTHONUTF8 = "1"
$env:PYTHONIOENCODING = "utf-8"
$PROJECT = Split-Path -Parent $PSScriptRoot
$LOG = "$PROJECT\logs\watchdog.log"
$utf8 = New-Object System.Text.UTF8Encoding $false
$LOG = "$PROJECT\logs\watchdog.log"
. "$PROJECT\scripts\stockbot_env.ps1"
$PYTHON = Resolve-StockBotPython -Project $PROJECT
$utf8 = New-Object System.Text.UTF8Encoding $false
Set-Location $PROJECT
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
[System.IO.File]::AppendAllText($LOG, "[$timestamp] 워치독 실행`n", $utf8)
function Write-WatchdogLog {
param([string]$Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
[System.IO.File]::AppendAllText($LOG, "[$timestamp] $Message`n", $utf8)
}
python scripts/_watchdog.py 2>&1 |
$holidayResult = & $PYTHON scripts\_is_trading_day.py 2>&1
if ($LASTEXITCODE -ne 0) {
Write-WatchdogLog "market closed - skipped ($holidayResult)"
exit 0
}
$now = Get-Date
$start = Get-Date -Hour 9 -Minute 0 -Second 0
$end = Get-Date -Hour 15 -Minute 10 -Second 59
if ($now -lt $start -or $now -gt $end) {
Write-WatchdogLog "outside watchdog window - skipped"
exit 0
}
Write-WatchdogLog "watchdog started"
& $PYTHON scripts\_watchdog.py 2>&1 |
ForEach-Object { [System.IO.File]::AppendAllText($LOG, "$_`n", $utf8) }
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
[System.IO.File]::AppendAllText($LOG, "[$timestamp] 워치독 완료`n", $utf8)
Write-WatchdogLog "watchdog finished"
+53 -70
View File
@@ -1,92 +1,75 @@
# StockBot 작업 스케줄러 등록 (전체 재등록용)
# 실행: powershell -ExecutionPolicy Bypass -File scripts\setup_scheduler.ps1
# 주의: 이 파일은 UTF-8 BOM으로 저장해야 한글 경로가 올바르게 등록됨
$ErrorActionPreference = "Stop"
# 콘솔 인코딩 UTF-8 강제 (한글 경로 깨짐 방지)
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$OutputEncoding = [System.Text.Encoding]::UTF8
chcp 65001 | Out-Null
$PROJECT = "C:\Users\whdwo\OneDrive\바탕 화면\stockbot_v3"
$weekdays = @(
[DayOfWeek]::Monday, [DayOfWeek]::Tuesday, [DayOfWeek]::Wednesday,
[DayOfWeek]::Thursday, [DayOfWeek]::Friday
$Project = Split-Path -Parent $PSScriptRoot
$TaskPath = "\StockBot\"
$Weekdays = @(
[DayOfWeek]::Monday,
[DayOfWeek]::Tuesday,
[DayOfWeek]::Wednesday,
[DayOfWeek]::Thursday,
[DayOfWeek]::Friday
)
function Register-StockTask($name, $time, $script, $limitMin) {
$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $weekdays -At $time
$action = New-ScheduledTaskAction `
function Register-StockTask {
param(
[string]$Name,
[string]$Time,
[string]$Script,
[int]$LimitMinutes
)
$Trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek $Weekdays -At $Time
$Action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$PROJECT\scripts\$script`"" `
-WorkingDirectory $PROJECT
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Minutes $limitMin) `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$Project\scripts\$Script`"" `
-WorkingDirectory $Project
$Settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Minutes $LimitMinutes) `
-StartWhenAvailable `
-DontStopIfGoingOnBatteries `
-RunOnlyIfNetworkAvailable:$false
$settings.DisallowStartIfOnBatteries = $false # 배터리 시작 제한 해제
Register-ScheduledTask -TaskName $name -TaskPath "\StockBot\" `
-Trigger $trigger -Action $action -Settings $settings -RunLevel Limited -Force | Out-Null
$Settings.DisallowStartIfOnBatteries = $false
# PowerShell 5.1 버그: Register-ScheduledTask가 한글 경로를 ANSI로 저장
# → XML export → 경로 수정 → 재import로 교정
$xml = Export-ScheduledTask -TaskName $name -TaskPath "\StockBot\"
$stored = (Get-ScheduledTask -TaskName $name -TaskPath "\StockBot\").Actions[0].Arguments
if (-not ($stored -match [regex]::Escape("바탕 화면"))) {
$garbled = ($stored -replace '.*OneDrive\\(.+?)\\stockbot.*', '$1')
if ($garbled -ne $stored) {
$fixedXml = $xml.Replace($garbled, "바탕 화면")
Register-ScheduledTask -TaskName $name -TaskPath "\StockBot\" -Xml $fixedXml -Force | Out-Null
}
}
Write-Host "[OK] $name 등록 완료 (평일 $time)" -ForegroundColor Green
Register-ScheduledTask `
-TaskName $Name `
-TaskPath $TaskPath `
-Trigger $Trigger `
-Action $Action `
-Settings $Settings `
-RunLevel Limited `
-Force | Out-Null
Write-Host "[OK] $Name registered at $Time" -ForegroundColor Green
}
# ── 태스크 등록 ────────────────────────────────────────────────────────────────
# 08:15 claude /morning → 뉴스+KIS 수집 → daily_context.json → /start-bot 호출
Register-StockTask "StockBot_Morning" "08:15" "run_morning.ps1" 20
# 11:20 claude /midday → 장중 스냅샷 → midday_context.json → 점심 세션 시작
Register-StockTask "StockBot_Midday" "11:20" "run_midday.ps1" 20
# 15:30 claude /evening → 결과 분석 → 리포트 → Discord
Register-StockTask "StockBot_Evening" "15:30" "run_evening.ps1" 30
# 워치독: 5분마다 봇 생존 확인 → 죽어있으면 Discord 알림 + 자동 재시작
function Register-WatchdogTask {
$times = @("09:00","09:05","09:10","09:15","09:20","09:25","09:30","09:35","09:40","09:45","09:50","09:55",
"10:00","10:05","10:10","10:15","10:20","10:25","10:30","10:35","10:40","10:45","10:50","10:55",
"11:00","11:05","11:10","11:15","11:20","11:25","11:30","11:35","11:40","11:45","11:50","11:55",
"12:00","12:05","12:10","12:15","12:20","12:25","12:30","12:35","12:40","12:45","12:50","12:55",
"13:00","13:05","13:10","13:15","13:20","13:25","13:30","13:35","13:40","13:45","13:50","13:55",
"14:00","14:05","14:10","14:15","14:20","14:25","14:30","14:35","14:40","14:45","14:50","14:55",
"15:00","15:05","15:10")
$triggers = $times | ForEach-Object {
New-ScheduledTaskTrigger -Weekly -DaysOfWeek $weekdays -At $_
$TaskName = "\StockBot\StockBot_Watchdog"
$ScriptPath = Join-Path $Project "scripts\run_watchdog.ps1"
$Command = 'schtasks /Create /TN "\StockBot\StockBot_Watchdog" /TR "\"powershell.exe\" -NonInteractive -ExecutionPolicy Bypass -File \"' + $ScriptPath + '\"" /SC MINUTE /MO 5 /ST 09:00 /ET 15:10 /F'
cmd.exe /c $Command | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "StockBot_Watchdog registration failed"
}
$action = New-ScheduledTaskAction `
-Execute "powershell.exe" `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$PROJECT\scripts\run_watchdog.ps1`"" `
-WorkingDirectory $PROJECT
$settings = New-ScheduledTaskSettingsSet `
-ExecutionTimeLimit (New-TimeSpan -Minutes 3) `
-StartWhenAvailable `
-MultipleInstances IgnoreNew `
-DontStopIfGoingOnBatteries
$settings.DisallowStartIfOnBatteries = $false
Register-ScheduledTask -TaskName "StockBot_Watchdog" -TaskPath "\StockBot\" `
-Trigger $triggers -Action $action -Settings $settings -RunLevel Limited -Force | Out-Null
Write-Host "[OK] StockBot_Watchdog 등록 완료 (평일 09:00~15:10, 5분 간격)" -ForegroundColor Green
Write-Host "[OK] StockBot_Watchdog registered at 09:00-15:10 every 5 minutes" -ForegroundColor Green
}
Register-StockTask "StockBot_Morning" "08:15" "run_morning.ps1" 20
Register-StockTask "StockBot_Midday" "11:20" "run_midday.ps1" 20
Register-StockTask "StockBot_Evening" "15:30" "run_evening.ps1" 30
Register-StockTask "StockBot_Training" "16:00" "run_training_pipeline.ps1" 60
Register-WatchdogTask
# StockBot_Bot 비활성화 유지 (이미 존재할 경우)
$botTask = Get-ScheduledTask -TaskName "StockBot_Bot" -TaskPath "\StockBot\" -ErrorAction SilentlyContinue
if ($botTask) {
Disable-ScheduledTask -TaskName "StockBot_Bot" -TaskPath "\StockBot\" | Out-Null
Write-Host "[OK] StockBot_Bot 비활성화 유지" -ForegroundColor Yellow
$BotTask = Get-ScheduledTask -TaskName "StockBot_Bot" -TaskPath $TaskPath -ErrorAction SilentlyContinue
if ($BotTask) {
Disable-ScheduledTask -TaskName "StockBot_Bot" -TaskPath $TaskPath | Out-Null
Write-Host "[OK] StockBot_Bot disabled" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "등록된 작업:" -ForegroundColor Cyan
Get-ScheduledTask -TaskPath "\StockBot\" | Format-Table TaskName, State
Write-Host "Registered StockBot tasks:" -ForegroundColor Cyan
Get-ScheduledTask -TaskPath $TaskPath | Format-Table TaskName, State
+55
View File
@@ -0,0 +1,55 @@
$ErrorActionPreference = "Stop"
function Get-StockBotProjectRoot {
return (Split-Path -Parent $PSScriptRoot)
}
function Resolve-StockBotPython {
param([string]$Project)
$VenvPython = Join-Path $Project ".venv\Scripts\python.exe"
if (Test-Path $VenvPython) {
return $VenvPython
}
$PyenvPython = Join-Path $env:USERPROFILE ".pyenv\pyenv-win\versions\3.11.9\python.exe"
if (Test-Path $PyenvPython) {
return $PyenvPython
}
$Command = Get-Command python -ErrorAction SilentlyContinue
if ($Command) {
return $Command.Source
}
throw "Python not found. Run Restore_StockBot.bat first."
}
function Resolve-StockBotClaude {
$Candidates = @()
if ($env:STOCKBOT_CLAUDE) {
$Candidates += $env:STOCKBOT_CLAUDE
}
if ($env:APPDATA) {
$Candidates += (Join-Path $env:APPDATA "npm\claude.cmd")
$Candidates += (Join-Path $env:APPDATA "npm\codex.cmd")
}
foreach ($Candidate in $Candidates) {
if ($Candidate -and (Test-Path $Candidate)) {
return $Candidate
}
}
$Claude = Get-Command claude.cmd -ErrorAction SilentlyContinue
if ($Claude) {
return $Claude.Source
}
$Codex = Get-Command codex.cmd -ErrorAction SilentlyContinue
if ($Codex) {
return $Codex.Source
}
throw "Claude/Codex CLI not found. Run Restore_StockBot.bat or install the CLI, then retry."
}
+179
View File
@@ -0,0 +1,179 @@
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
import joblib
import pandas as pd
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, precision_score, roc_auc_score
from sklearn.model_selection import train_test_split
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.ml.features import LABEL_COLUMNS, build_feature_matrix, select_feature_columns
DEFAULT_INPUTS = [
Path("data/training_dataset.csv"),
Path("data/external_training_dataset.csv"),
]
def _read_csv(path: Path) -> pd.DataFrame:
if not path.exists() or path.stat().st_size == 0:
return pd.DataFrame()
return pd.read_csv(path)
def _load_inputs(paths: list[Path]) -> pd.DataFrame:
frames = []
for path in paths:
frame = _read_csv(path)
if not frame.empty:
frame["source_file"] = str(path)
frames.append(frame)
if not frames:
return pd.DataFrame()
return pd.concat(frames, ignore_index=True, sort=False)
def _train_one_target(
x: pd.DataFrame,
y: pd.Series,
random_state: int,
) -> tuple[RandomForestClassifier, dict[str, float | int]]:
stratify = y if y.nunique() == 2 and y.value_counts().min() >= 2 else None
x_train, x_test, y_train, y_test = train_test_split(
x,
y,
test_size=0.25,
random_state=random_state,
stratify=stratify,
)
model = RandomForestClassifier(
n_estimators=300,
max_depth=7,
min_samples_leaf=10,
class_weight="balanced_subsample",
random_state=random_state,
n_jobs=-1,
)
model.fit(x_train, y_train)
prediction = model.predict(x_test)
metrics: dict[str, float | int] = {
"rows": int(len(y)),
"train_rows": int(len(y_train)),
"test_rows": int(len(y_test)),
"positive_rows": int(y.sum()),
"accuracy": float(accuracy_score(y_test, prediction)),
"precision": float(precision_score(y_test, prediction, zero_division=0)),
}
if y_test.nunique() == 2 and hasattr(model, "predict_proba"):
metrics["roc_auc"] = float(roc_auc_score(y_test, model.predict_proba(x_test)[:, 1]))
return model, metrics
def train(args: argparse.Namespace) -> int:
df = _load_inputs(args.inputs)
if df.empty:
print("No training rows found. Export datasets first.")
return 0
targets = [target for target in args.targets if target in df.columns]
if not targets:
print("No supported label columns found.")
return 0
if len(df) < args.min_rows:
print(f"Only {len(df)} rows found. Need at least {args.min_rows} rows before training.")
return 0
feature_columns = select_feature_columns(df, targets)
if not feature_columns:
print("No numeric feature columns found.")
return 0
x, medians = build_feature_matrix(df, feature_columns)
models = {}
metrics = {
"created_at": datetime.now().isoformat(timespec="seconds"),
"input_files": [str(path) for path in args.inputs],
"feature_count": len(feature_columns),
"features": feature_columns,
"targets": {},
}
for target in targets:
labeled = df[target].dropna()
target_index = labeled.index
y = pd.to_numeric(labeled, errors="coerce").dropna().astype(int)
target_x = x.loc[y.index]
if len(y) < args.min_rows:
print(f"Skipping {target}: only {len(y)} labeled rows.")
continue
if y.nunique() < 2:
print(f"Skipping {target}: target has only one class.")
continue
model, target_metrics = _train_one_target(target_x, y, args.random_state)
models[target] = model
metrics["targets"][target] = target_metrics
print(f"Trained {target}: {target_metrics}")
if not models:
print("No model was trained.")
return 0
args.out.parent.mkdir(parents=True, exist_ok=True)
bundle = {
"created_at": metrics["created_at"],
"feature_columns": feature_columns,
"medians": medians,
"models": models,
"metrics": metrics,
}
joblib.dump(bundle, args.out)
metrics_path = args.metrics_out or args.out.with_suffix(".metrics.json")
metrics_path.write_text(json.dumps(metrics, indent=2), encoding="utf-8")
print(f"Saved model: {args.out}")
print(f"Saved metrics: {metrics_path}")
return 0
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Train scalping AI models from exported datasets.")
parser.add_argument(
"--inputs",
nargs="+",
type=Path,
default=DEFAULT_INPUTS,
help="CSV datasets to combine.",
)
parser.add_argument("--out", type=Path, default=Path("models/scalping_model.joblib"))
parser.add_argument("--metrics-out", type=Path, default=None)
parser.add_argument("--min-rows", type=int, default=200)
parser.add_argument("--random-state", type=int, default=42)
parser.add_argument(
"--targets",
nargs="+",
default=sorted(LABEL_COLUMNS),
choices=sorted(LABEL_COLUMNS),
)
return parser.parse_args()
if __name__ == "__main__":
raise SystemExit(train(parse_args()))