Files
QuantEngineByItz/tools/refresh_trading_calendar.py
kjh2064 ee3e799de1 feat: 리밸런싱 엔진 V1 + GAS 버그 수정 (2026-06-13)
주요 변경:
- tools/build_rebalance_engine_v1.py: REBALANCE_ENGINE_V1 신규
  * account_snapshot 직접 합산(_build_snap_position_map) → 소수주 분리 행 병합
  * 레짐 소스 macro.REGIME_PRELIM 최우선 (GAS 와 동일)
- src/gas_adapter_parts/gdf_06_rebalance.gs: runRebalanceSheet_() 신규
  * Logger.log / getSpreadsheet_() 로 run_all 연동 수정
- src/gas_adapter_parts/gdc_01_fetch_fundamentals.gs
  * _mergePositionRecord_(): 소수주 중복 행 합산 신규
  * parseInt → parseFloat (qty, availQty)
- src/gas_adapter_parts/gdf_01_price_metrics.gs
  * 미보유 종목 SELL_READY → WATCH_EXIT_SIGNAL
- spec/41_release_dag.yaml: build_rebalance_sheet 노드 추가 (step_count 63)
- spec/51_formula_lifecycle_registry.yaml: REBALANCE_ENGINE_V1 등록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 13:20:14 +09:00

270 lines
10 KiB
Python

import os
import re
import sys
import hashlib
import requests
from datetime import datetime
from pathlib import Path
from bs4 import BeautifulSoup
import openpyxl
# Reconfigure stdout for UTF-8 to prevent CP949 encoding crashes on Windows
sys.stdout.reconfigure(encoding='utf-8')
ROOT = Path(__file__).resolve().parent.parent
XLSX_PATH = ROOT / "GatherTradingData.xlsx"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
}
url = "https://tradingeconomics.com/calendar"
TYPE_MAP = [
{ 'keys': ['FOMC','연준','Federal Open Market','Fed Rate'], 'type': 'FOMC' },
{ 'keys': ['CPI','소비자물가','Consumer Price','Inflation'], 'type': None },
{ 'keys': ['PPI','생산자물가','Producer Price'], 'type': 'US_PPI' },
{ 'keys': ['PCE','개인소비지출','Personal Consumption'], 'type': 'US_PCE' },
{ 'keys': ['NFP','비농업','Nonfarm','Payroll'], 'type': 'US_NFP' },
{ 'keys': ['실적','잠정실적','Earnings','EPS','Revenue'], 'type': 'EARNINGS' },
{ 'keys': ['옵션만기','선물만기','만기일','Expiry','Triple Witching'], 'type': 'EXPIRY' },
{ 'keys': ['한국은행','금통위','BOK','Bank of Korea'], 'type': 'BOK' },
{ 'keys': ['환율','FX','Dollar','달러'], 'type': 'FX' },
{ 'keys': ['국채','채권','Bond','Treasury','KTB'], 'type': 'BOND' },
{ 'keys': ['BOJ','일본은행','Bank of Japan','BOJ Rate','BOJ Interest'], 'type': 'BOJ' },
]
def guessEventType(eventName, region):
upper = eventName.upper()
reg = region.upper().strip()
for rule in TYPE_MAP:
if any(k.upper() in upper for k in rule['keys']):
if rule['type'] is None:
if reg == 'KR' or '한국' in upper or 'KR' in upper:
return 'KR_CPI'
if reg == 'US' or '미국' in upper or 'US' in upper:
return 'US_CPI'
return 'CUSTOM'
us_only_types = ['US_PPI', 'US_PCE', 'US_NFP', 'FOMC']
if rule['type'] in us_only_types and reg != 'US' and reg != '':
return 'CUSTOM'
if rule['type'] == 'BOJ' and reg != 'JP' and reg != '':
return 'CUSTOM'
return rule['type']
return 'CUSTOM'
def guessImpact(type_str, eventName):
high_types = ['FOMC','US_CPI','US_NFP','BOK','KR_CPI','BOJ']
med_types = ['US_PPI','US_PCE','EARNINGS','EXPIRY']
if type_str in high_types:
return 'HIGH'
if type_str in med_types:
return 'MEDIUM'
return 'LOW'
def build_key(date_str, event_name, type_str):
raw = f"{date_str}|{type_str.upper()}|{event_name.strip()}"
return hashlib.md5(raw.encode('utf-8')).hexdigest()
def main() -> int:
print(f"Loading Excel workbook from {XLSX_PATH}...")
if not XLSX_PATH.exists():
print(f"Error: {XLSX_PATH} does not exist!")
return 1
wb = openpyxl.load_workbook(XLSX_PATH)
sheet_name = "event_calendar"
# Auto-create sheet if missing
if sheet_name not in wb.sheetnames:
print(f"Sheet '{sheet_name}' not found. Creating a new one...")
ws = wb.create_sheet(sheet_name)
default_headers = ['Date', 'Event', 'Type', 'Impact', 'Alert', 'DaysLeft', 'AlertStatus', 'LastCheckedAt', 'Source', 'SourceUrl', 'Key']
ws.append(default_headers)
else:
ws = wb[sheet_name]
# Ensure all required headers exist in the sheet. Append them automatically if missing.
headers_list = [cell.value for cell in ws[1]]
header_map = {name: idx + 1 for idx, name in enumerate(headers_list) if name}
all_required_headers = ['Date', 'Event', 'Type', 'Impact', 'Alert', 'DaysLeft', 'AlertStatus', 'LastCheckedAt', 'Source', 'SourceUrl', 'Key']
ws_updated = False
for req in all_required_headers:
if req not in header_map:
new_col_idx = len(headers_list) + 1
ws.cell(row=1, column=new_col_idx, value=req)
headers_list.append(req)
header_map[req] = new_col_idx
print(f"Automatically added missing header column '{req}' at column index {new_col_idx}")
ws_updated = True
if ws_updated:
wb.save(XLSX_PATH)
print("Excel workbook headers updated and saved.")
# Index existing keys to avoid duplicates
key_col = header_map['Key']
row_by_key = {}
for r_idx in range(2, ws.max_row + 1):
k = ws.cell(row=r_idx, column=key_col).value
if k:
row_by_key[k] = r_idx
# Calculate date range (60 days ahead)
today = datetime.now().date()
today_str = today.strftime("%Y-%m-%d")
# Calculate 60 days ahead date
from datetime import timedelta
end_date = today + timedelta(days=60)
end_date_str = end_date.strftime("%Y-%m-%d")
print(f"Requesting Trading Economics calendar for range: {today_str} to {end_date_str}...")
headers_req = headers.copy()
headers_req['Cookie'] = f"cal-custom-range={today_str}|{end_date_str}"
try:
resp = requests.get(url, headers=headers_req, timeout=12)
if resp.status_code != 200:
print(f"Error: Fetch failed with HTTP status {resp.status_code}")
return 1
soup = BeautifulSoup(resp.text, 'html.parser')
t = soup.find('table', id='calendar')
if not t:
print("Error: Could not find calendar table in HTML response.")
return 1
rows = t.find_all('tr')
print(f"Found {len(rows)} raw HTML rows. Starting parser...")
upsert_count = 0
insert_count = 0
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
for r in rows:
if not r.get('data-event'):
continue
tds = r.find_all('td')
if len(tds) < 9:
continue
# Date from td[0] class
td0 = tds[0]
date_classes = td0.get('class', [])
date_str = ""
for c in date_classes:
if re.match(r'^\d{4}-\d{2}-\d{2}$', c):
date_str = c
break
if not date_str:
continue
# Impact (parse stars from td0 html snippet)
td0_html = str(td0)
impact = 'LOW'
if 'calendar-date-3' in td0_html:
impact = 'HIGH'
elif 'calendar-date-2' in td0_html:
impact = 'MEDIUM'
# Country ISO code from td[3]
country_iso = tds[3].get_text(strip=True).upper()
# Event name from a.calendar-event inside td[4]
a_ev = tds[4].find('a', class_='calendar-event')
if not a_ev:
continue
eventName = a_ev.get_text(strip=True)
# Skip noise countries (US, KR, JP only)
if country_iso not in ['US', 'KR', 'JP']:
continue
type_str = guessEventType(eventName, country_iso)
final_impact = guessImpact(type_str, eventName)
if final_impact == 'LOW' and impact != 'LOW':
final_impact = impact
# Skip LOW impact CUSTOM (Except for South Korea)
if type_str == 'CUSTOM' and final_impact == 'LOW' and country_iso != 'KR':
continue
# Actual, Previous, Consensus
def clean_text(td_el):
val = re.sub(r'<[^>]+>', ' ', str(td_el))
val = re.sub(r'\s+', ' ', val).strip()
return val
actual = clean_text(tds[5])
previous = clean_text(tds[6])
consensus = clean_text(tds[7])
alert_text_list = []
if actual and actual != '-':
alert_text_list.append(f"Act: {actual}")
if consensus and consensus != '-':
alert_text_list.append(f"Est: {consensus}")
if previous and previous != '-':
alert_text_list.append(f"Prev: {previous}")
alert_str = " ".join(alert_text_list)
# Calculate DaysLeft
try:
ev_date = datetime.strptime(date_str, "%Y-%m-%d").date()
days_left = (ev_date - today).days
except Exception:
days_left = ""
key = build_key(date_str, eventName, type_str)
# Prepare row cells mapping
row_data = {
'Date': date_str,
'Event': eventName,
'Type': type_str,
'Impact': final_impact,
'Alert': alert_str,
'DaysLeft': days_left,
'LastCheckedAt': now_str,
'Source': 'Trading Economics',
'SourceUrl': 'https://tradingeconomics.com/calendar',
'Key': key
}
if key in row_by_key:
# Update existing row
r_num = row_by_key[key]
for col_name, val in row_data.items():
col_idx = header_map[col_name]
ws.cell(row=r_num, column=col_idx, value=val)
upsert_count += 1
else:
# Append new row
max_col = max(header_map.values())
new_row = ["" for _ in range(max_col)]
for col_name, val in row_data.items():
col_idx = header_map[col_name]
new_row[col_idx - 1] = val
ws.append(new_row)
row_by_key[key] = ws.max_row # keep index updated
insert_count += 1
print(f"Parser complete. Added {insert_count} new events, Updated {upsert_count} existing events.")
# Save Excel file
print(f"Saving workbook back to {XLSX_PATH}...")
wb.save(XLSX_PATH)
print("Excel workbook successfully updated!")
return 0
except Exception as e:
print("Failed to run refresh script:", e)
return 1
if __name__ == "__main__":
raise SystemExit(main())