<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>투자자동화 보관 - 하우인포-IT·테크</title>
	<atom:link href="https://howinfo.kr/tag/%ED%88%AC%EC%9E%90%EC%9E%90%EB%8F%99%ED%99%94/feed/" rel="self" type="application/rss+xml" />
	<link>https://howinfo.kr/tag/투자자동화/</link>
	<description>IT·AI 자동화 &#38; 인프라 전문 블로그 (하우인포)</description>
	<lastBuildDate>Sat, 21 Feb 2026 02:24:31 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.1</generator>

<image>
	<url>https://howinfo.kr/wp-content/uploads/2026/02/cropped-ChatGPT-Image-2026년-2월-12일-오후-05_39_40-32x32.png</url>
	<title>투자자동화 보관 - 하우인포-IT·테크</title>
	<link>https://howinfo.kr/tag/투자자동화/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>개인 투자용 주식 자동화 시스템 구조 설명</title>
		<link>https://howinfo.kr/%ec%9d%b4-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%ed%95%98%eb%82%98%ec%97%90-%ed%88%ac%ec%9e%90-%ec%9e%90%eb%8f%99%ed%99%94%ec%9d%98-%eb%aa%a8%eb%93%a0-%ea%b2%83/</link>
					<comments>https://howinfo.kr/%ec%9d%b4-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%ed%95%98%eb%82%98%ec%97%90-%ed%88%ac%ec%9e%90-%ec%9e%90%eb%8f%99%ed%99%94%ec%9d%98-%eb%aa%a8%eb%93%a0-%ea%b2%83/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Wed, 04 Feb 2026 06:41:49 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[SynologyChat]]></category>
		<category><![CDATA[네이버주가 api]]></category>
		<category><![CDATA[분할매수계산]]></category>
		<category><![CDATA[손순익계산]]></category>
		<category><![CDATA[주식자동화]]></category>
		<category><![CDATA[투자자동화]]></category>
		<category><![CDATA[파이썬주식봇]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1260</guid>

					<description><![CDATA[<p>“단순 알림 봇이 아니라 계산 엔진 중심 구조” (FULL 소스 제공) 📌 상단 요약 (5줄) 1️⃣ 왜 이런 구조가 필요했는가...</p>
<p>게시물 <a href="https://howinfo.kr/%ec%9d%b4-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%ed%95%98%eb%82%98%ec%97%90-%ed%88%ac%ec%9e%90-%ec%9e%90%eb%8f%99%ed%99%94%ec%9d%98-%eb%aa%a8%eb%93%a0-%ea%b2%83/">개인 투자용 주식 자동화 시스템 구조 설명</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 class="wp-block-heading">“단순 알림 봇이 아니라 계산 엔진 중심 구조” (FULL 소스 제공)</h2>



<h2 class="wp-block-heading">📌 상단 요약 (5줄)</h2>



<ul class="wp-block-list">
<li>네이버에서 주가를 가져와 실시간 손익을 계산하는 자동화 시스템</li>



<li>단순 평가손익이 아닌 수수료·세금 포함 순손익 계산</li>



<li>분할매수(LOTS) 구조까지 정확히 처리</li>



<li>환율 자동 반영으로 해외 ETF까지 KRW 기준 계산</li>



<li>Synology Chat으로 장중 자동 알림 전송 + 히스토리 저장</li>
</ul>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">1️⃣ 왜 이런 구조가 필요했는가</h2>



<p>일반적인 주식 봇은 대부분 다음과 같습니다.</p>



<pre class="wp-block-preformatted">(현재가 - 매입가) × 수량</pre>



<p>하지만 실제 투자에서는 이 계산이 정확하지 않습니다.</p>



<ul class="wp-block-list">
<li>매수 수수료</li>



<li>매도 수수료</li>



<li>거래세</li>



<li>분할 매수 평단</li>



<li>ETF 거래세 면제 여부</li>



<li>해외 ETF 환율 반영</li>
</ul>



<p>이 요소를 모두 반영하지 않으면<br>HTS와 실제 계산 값이 달라집니다.</p>



<p>이 시스템은 그 차이를 줄이기 위해 설계되었습니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">2️⃣ 데이터 수집 구조 (네이버 주가 안정화 처리)</h2>



<p>이 코드는 단순 <code>requests.get()</code> 구조가 아닙니다.</p>



<p>포함된 안정화 로직:</p>



<ul class="wp-block-list">
<li>네이버 응답 형식 변경 대응</li>



<li>자동 재시도</li>



<li>연결/읽기 타임아웃 분리</li>



<li>세션 재사용</li>



<li>실패 시 쿨다운</li>



<li>일시적 불안정 구간 보호</li>
</ul>



<p>👉 장기 운영을 고려한 네트워크 방어 구조</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">3️⃣ 손익 계산 엔진 (핵심 로직)</h2>



<h3 class="wp-block-heading">① 실제 순손익 계산</h3>



<p>반영 항목:</p>



<ul class="wp-block-list">
<li>매수 수수료</li>



<li>매도 수수료</li>



<li>주식 매도 거래세</li>



<li>ETF 거래세 0 처리</li>



<li>매수 수수료 취득원가 포함 여부 옵션</li>



<li>증권사별 수수료율 반영</li>
</ul>



<p>즉,</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>HTS에서 보는 계산 방식과 동일 구조</p>
</blockquote>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h3 class="wp-block-heading">② LOTS 구조 처리</h3>



<p>예시:</p>



<pre class="wp-block-preformatted">"lots": [<br>  {"buy_price": 30000, "qty": 5},<br>  {"buy_price": 32000, "qty": 3}<br>]</pre>



<p>처리 내용:</p>



<ul class="wp-block-list">
<li>평단 자동 계산</li>



<li>총 투자원금 계산</li>



<li>LOT별 손익 합산</li>



<li>분할매수 대응</li>
</ul>



<p>👉 물타기/분할 매수 전략에 필수</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">4️⃣ 환율 자동 반영 시스템</h2>



<p>해외 ETF 대응을 위해 다음 구조가 포함되어 있습니다.</p>



<ul class="wp-block-list">
<li>exchangerate.host 기본 호출</li>



<li>실패 시 frankfurter.app 폴백</li>



<li>필요한 통화만 계산</li>



<li>주기적 자동 갱신</li>
</ul>



<p>즉,</p>



<p>USD 자산을 KRW 기준으로 정확히 환산</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">5️⃣ 실행 모드 제어</h2>



<p>지원 옵션:</p>



<pre class="wp-block-preformatted">--market-hours 09:00~15:30<br>--every-min 10<br>--holiday-file holidays.json</pre>



<p>가능 기능:</p>



<ul class="wp-block-list">
<li>장중만 실행</li>



<li>N분 간격 반복</li>



<li>주말 자동 제외</li>



<li>휴장일 자동 제외</li>
</ul>



<p>👉 수동 개입 없이 자동 운용</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">6️⃣ 임계치 알림 시스템</h2>



<p>예시:</p>



<pre class="wp-block-preformatted">--alert-pl-pct 10<br>--alert-total-pl-abs 1000000</pre>



<p>동작:</p>



<ul class="wp-block-list">
<li>종목 수익률 10% 이상 알림</li>



<li>총 손익 100만원 이상 알림</li>



<li>쿨다운 기능으로 중복 방지</li>
</ul>



<p>👉 실전 투자에 중요한 리스크/수익 관리 기능</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">7️⃣ 히스토리 저장 구조</h2>



<p>포함 기능:</p>



<pre class="wp-block-preformatted">append_history_jsonl(...)</pre>



<p>의미:</p>



<ul class="wp-block-list">
<li>실행 시점 스냅샷 저장</li>



<li>JSONL 형식 로그 축적</li>
</ul>



<p>확장 가능성:</p>



<ul class="wp-block-list">
<li>웹 대시보드</li>



<li>수익률 그래프</li>



<li>월간 리포트 자동 생성</li>



<li>투자 패턴 분석</li>
</ul>



<p>이미 데이터 기반이 준비된 상태</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">8️⃣ Synology Chat 전송 구조</h2>



<p>일반적인 webhook과 차이점:</p>



<ul class="wp-block-list">
<li>payload / json / text 3단계 전송 시도</li>



<li>411 에러 retry 처리</li>



<li>전송 간격 제한</li>



<li>긴 메시지 자동 분할</li>



<li>실패 시 에러 웹훅 분리 전송</li>
</ul>



<p>👉 실사용 환경에서 발생 가능한 예외 대응 포함</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">9️⃣ 왜 ‘실전용’ 구조인가</h2>



<p>이 시스템은 단순 예제가 아니라:</p>



<ul class="wp-block-list">
<li>네이버 응답 변동 대응</li>



<li>실제 증권사 손익 방식 반영</li>



<li>분할 매수 처리</li>



<li>환율 폴백 구조</li>



<li>장중 자동화</li>



<li>임계치 알림</li>



<li>히스토리 축적</li>
</ul>



<p>운영 중 발생하는 문제를 반영한 구조입니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">🔎 확장 가능 영역</h2>



<p>이미 계산 엔진과 로그 저장 구조가 완성되어 있으므로:</p>



<ul class="wp-block-list">
<li>SQLite/DB 연동</li>



<li>웹 대시보드 구축</li>



<li>월간 자동 리포트</li>



<li>투자 전략 분석 엔진</li>



<li>AI 기반 매매 패턴 분석</li>
</ul>



<p>으로 확장이 가능합니다.</p>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">실행 방법</h2>



<pre class="wp-block-preformatted">python 파일명.py</pre>



<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h2 class="wp-block-heading">결론</h2>



<p>이 파일은 단순 “주가 알림 봇”이 아니라</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>개인 투자자를 위한 계산 중심 자동화 엔진</p>
</blockquote>



<p>에 가깝습니다.</p>



<p>코드를 전부 이해하는 것보다<br>구조와 확장 가능성을 이해하는 것이 더 중요합니다.</p>



<p>그리고 이 코드는<br>그걸 충분히 해낼 수 있는 구조를 이미 가지고 있습니다.</p>



<p>아래 full 소스 제공해 드립니다. </p>



<p>실행방법 python 파일명.py </p>



<pre class="wp-block-code"><code># -*- coding: utf-8 -*-
"""
Synology Chat(Webhook)으로 네이버 주가/ETF 전송 봇 (Python 3.9+)
- LOTS(분할매수), 분배금 반영, ETF만 알림, 환율 환산
- 자동 환율(exchangerate.host → frankfurter.app 폴백) + 최초 보장
- 데몬/장중, 휴장일, 재시도 세션, Graceful 종료(CTRL+C/신호)
- 수수료/거래세 반영 순손익(키움증권 등) 계산
- 커미션 기본값: --venue krx(0.015%), nxt(0.0145%) | 직접 지정은 --commission-bps
- 웹훅 성공 판정(200/204/JSON success), 레이트리밋 지터, 상태파일 잠금(옵션)
"""

import argparse, time, json, urllib3, math, os, signal, logging, sys, re, random, atexit
from datetime import datetime, timedelta, time as dtime, date as ddate, timezone
from typing import Optional, Set, Dict, Any, Tuple, List
import requests
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode

# (optional) 파일락: Linux/Synology 권장
try:
    import fcntl  # type: ignore
    _HAS_FCNTL = True
except Exception:
    _HAS_FCNTL = False

# ===== 로깅 유틸 =====
LOG_BODY_MAX = 3000  # 응답 본문은 너무 길면 초과분 생략

def _mask_url(url: str) -&gt; str:
    """웹훅 token, 키 등 쿼리스트링 민감정보 마스킹"""
    try:
        sp = urlsplit(url)
        qs = parse_qsl(sp.query, keep_blank_values=True)
        masked = &#91;]
        SENSITIVE = {
            "token","apikey","appkey","secret","secretkey","appsecret",
            "hook","webhook","authorization","sig","signature"
        }
        for k, v in qs:
            if k.lower() in SENSITIVE or re.search(r"(token|secret|sig|hook|auth)", k, re.I):
                masked.append((k, "***"))
            else:
                masked.append((k, v))
        new_q = urlencode(masked, doseq=True)
        return urlunsplit((sp.scheme, sp.netloc, sp.path, new_q, sp.fragment))
    except Exception:
        return url

def _log_http(resp: requests.Response, label: str = ""):
    """요청/응답 핵심을 구조적으로 출력"""
    try:
        req = resp.request
        masked_url = _mask_url(req.url)
        logging.error(
            "%sHTTP %s %s -&gt; %s\n&#91;req headers]=%s\n&#91;req body]=%s\n&#91;resp headers]=%s\n&#91;resp body]=%s",
            (f"&#91;{label}] " if label else ""),
            req.method, masked_url, resp.status_code,
            dict(req.headers),
            (req.body.decode("utf-8", errors="ignore") if isinstance(req.body, bytes) else str(req.body))&#91;:LOG_BODY_MAX],
            dict(resp.headers),
            (resp.text or "")&#91;:LOG_BODY_MAX],
        )
    except Exception as e:
        logging.exception("&#91;_log_http] 로깅 중 예외: %s", e)

# -------------------- 로깅 --------------------
logging.basicConfig(level=logging.INFO, format="%(asctime)s &#91;%(levelname)s] %(message)s")
log = logging.getLogger("naver-syno-bot")

# =====================&#91; 환경변수 / 기본설정 ]=====================
CHAT_WEBHOOK_URL = os.getenv("CHAT_WEBHOOK_URL", "").strip()
CHAT_ERROR_WEBHOOK_URL = os.getenv("CHAT_ERROR_WEBHOOK_URL", "").strip()

VERIFY_SSL = True
DEFAULT_INTERVAL = 1200   # 20분
DEFAULT_TZ = "Asia/Seoul"
DEFAULT_REPORT_CCY = "KRW"

# 네이버 시세 요청 타임아웃(연결, 읽기)
NAVER_TIMEOUT = (3.0, 7.0)

# 네이버 조회 실패 쿨다운(초): 같은 종목이 연속 실패할 때 잠시 스킵(네이버 불안정 완화)
FAIL_COOLDOWN_SEC = 30
_failed_cache: Dict&#91;str, float] = {}  # code -&gt; last_fail_ts

# 임계치 알림 쿨다운(초): 임계 충족 상태가 유지될 때 알림 스팸 방지
ALERT_COOLDOWN_SEC = 1800  # 30분

# (옵션) 단일 인스턴스 실행 보장 PID 파일
DEFAULT_PID_FILE = "/tmp/naver_stock_bot.pid"

# 히스토리 저장(월간 리포트용)
DEFAULT_HISTORY_DIR = "./reports"
DEFAULT_HISTORY_PREFIX = "history"


# 기본 종목/포트폴리오 — 파일로 대체 가능 (중복키 주의)
STOCKS: Dict&#91;str, Any] = {
    "SK하이닉스": "000660",
    "TIGER 미국필라델피아AI반도체나스닥": "497570",
}
PORTFOLIO: Dict&#91;str, Any] = {
    "SK하이닉스": {"buy_price": 337_788, "qty": 13},
    "TIGER 미국필라델피아AI반도체나스닥": {"buy_price": 13_069, "qty": 140}
}
DIVIDENDS: Dict&#91;str, Any] = {}  # {"이름":{"currency":"KRW","items":&#91;{"total":..}|{"per_share":..,"qty":..}]}}
FX_RATES: Dict&#91;str, float] = {"KRW": 1.0}

# ==== Fee/Tax (Kiwoom 등) ====
_commission_rate = 0.0             # 예: 0.015% -&gt; 0.00015
_sell_tax_rate_stock = 0.0         # 일반 주식 매도세율(%)
_sell_tax_rate_etf = 0.0           # ETF 매도세율(%). 한국 ETF는 보통 0.0
_include_buy_commission_in_cost = True  # 매수 커미션을 취득원가에 포함할지

# 네이버 비공식 가격 API
BASE = "https://m.stock.naver.com/api/stock/{code}/price"
HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json, text/plain, */*", "Referer": "https://m.stock.naver.com/"}

# 실행 상태
_stop = False
_sessions: Dict&#91;bool, requests.Session] = {}  # verify별 세션 캐시

# ---- 자동 환율 상태 ----
_auto_fx_enabled = False
_auto_fx_extra: Set&#91;str] = set()
_fx_refresh_minutes = 720
_last_fx_fetch: Optional&#91;datetime] = None

# ---- 전송 최소 간격(폭주 방지) ----
_last_send_ts: Optional&#91;float] = None
_min_send_gap_sec: float = 1.5

# -------------------- 시그널 핸들러 --------------------
def _install_signal_handlers():
    def handler(signum, frame):
        global _stop
        _stop = True
        log.info("신호(%s) 수신 → 종료 준비...", signum)
    signal.signal(signal.SIGINT, handler)
    try:
        signal.signal(signal.SIGTERM, handler)
    except Exception:
        pass
    if hasattr(signal, "SIGBREAK"):
        try:
            signal.signal(signal.SIGBREAK, handler)
        except Exception:
            pass

# -------------------- HTTP 세션(재시도 포함) --------------------
def _build_session(verify_ssl: bool) -&gt; requests.Session:
    from urllib3.util.retry import Retry
    from requests.adapters import HTTPAdapter
    sess = requests.Session()
    retry = Retry(
        total=5, backoff_factor=0.8,
        status_forcelist=(429, 500, 502, 503, 504),
        allowed_methods=frozenset(&#91;"GET", "POST"]),
        raise_on_status=False, respect_retry_after_header=True
    )
    adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10)
    sess.mount("https://", adapter); sess.mount("http://", adapter)
    sess.verify = verify_ssl
    if not verify_ssl:
        urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
    return sess

def _get_session(verify_ssl: bool) -&gt; requests.Session:
    s = _sessions.get(bool(verify_ssl))
    if s is None:
        s = _build_session(verify_ssl)
        _sessions&#91;bool(verify_ssl)] = s
    return s

# -------------------- 유틸 --------------------
def _tzinfo(tz_name: str):
    try:
        from zoneinfo import ZoneInfo
        return ZoneInfo(tz_name)
    except Exception:
        return timezone(timedelta(hours=9))  # KST fallback

def _to_float(x):
    if x is None: return None
    if isinstance(x, (int, float)): return float(x)
    s = str(x).replace(",", "").strip()
    if s in ("", "-", "null", "None"): return None
    try: return float(s)
    except Exception: return None

def _fmt_money(v: float, ccy: str = "KRW") -&gt; str:
    return f"{v:,.0f}{'원' if ccy=='KRW' else ' ' + ccy}"

def _fmt_signed(v: float, c: str = "KRW") -&gt; str:
    sign = "+" if v &gt; 0 else ("-" if v &lt; 0 else "±")
    return f"{sign}{abs(v):,.0f}{'원' if c=='KRW' else ' ' + c}"

def _fmt_signed_pct(v: float) -&gt; str:
    sign = "+" if v &gt; 0 else ("-" if v &lt; 0 else "±")
    return f"{sign}{abs(v):.2f}%"

def _is_weekday(dt: datetime) -&gt; bool:
    return dt.weekday() &lt; 5

def _within(dt: datetime, start_t: dtime, end_t: dtime) -&gt; bool:
    now_t = dt.time()
    return (now_t &gt;= start_t) and (now_t &lt;= end_t)

def _ceil_to_next_minutes(dt: datetime, every_min: int) -&gt; datetime:
    total_secs = dt.hour * 3600 + dt.minute * 60 + dt.second
    step = every_min * 60
    next_total = math.ceil((total_secs + 1) / step) * step
    next_hour = next_total // 3600
    next_min = (next_total % 3600) // 60
    next_sec = next_total % 60
    next_dt = dt.replace(hour=0, minute=0, second=0, microsecond=0) + timedelta(hours=next_hour, minutes=next_min, seconds=next_sec)
    if next_dt.date() != dt.date():
        next_dt = datetime(dt.year, dt.month, dt.day, tzinfo=dt.tzinfo) + timedelta(days=1)
        next_dt = next_dt.replace(hour=0, minute=0, second=0, microsecond=0)
    return next_dt

# -------------------- JSON 관대 파서 --------------------
_COMMENT_RE = re.compile(r"(/\*.*?\*/|//&#91;^\n]*\n)", re.S)
_TRAILING_COMMA_RE = re.compile(r",\s*(&#91;}\]])")

def _load_json_lenient(path: str):
    with open(path, "r", encoding="utf-8") as f:
        txt = f.read()
    if txt.startswith("\ufeff"):
        txt = txt.lstrip("\ufeff")
    txt = _COMMENT_RE.sub("\n", txt)
    txt = _TRAILING_COMMA_RE.sub(r"\1", txt)
    return json.loads(txt)

def _load_json(path: str):
    try:
        return _load_json_lenient(path)
    except Exception:
        with open(path, "r", encoding="utf-8") as f:
            raw = f.read()
        return json.loads(raw)

def _parse_hhmm(s: str) -&gt; dtime:
    hh, mm = s.strip().split(":"); return dtime(int(hh), int(mm))

def _parse_holiday_dates(h_strings) -&gt; Set&#91;ddate]:
    res: Set&#91;ddate] = set()
    for s in h_strings or &#91;]:
        s = str(s).strip()
        if not s: continue
        try:
            y, m, d = s.split("-"); res.add(ddate(int(y), int(m), int(d)))
        except Exception:
            log.warning("&#91;경고] 휴장일 형식 무시: %s (YYYY-MM-DD)", s)
    return res

def _load_holidays_from_file(path: str) -&gt; Set&#91;ddate]:
    out: Set&#91;ddate] = set()
    if not path or not os.path.exists(path): return out
    try:
        if path.lower().endswith(".json"):
            data = _load_json(path)
            if isinstance(data, list):
                return _parse_holiday_dates(&#91;str(x) for x in data])
            if isinstance(data, dict) and "holidays" in data and isinstance(data&#91;"holidays"], list):
                return _parse_holiday_dates(&#91;str(x) for x in data&#91;"holidays"]])
            log.warning("&#91;경고] JSON은 배열 또는 {'holidays':&#91;...]} 지원"); return out
        else:
            with open(path, "r", encoding="utf-8") as f:
                lines = &#91;line.strip().split(",")&#91;0] for line in f if line.strip()]
            return _parse_holiday_dates(lines)
    except Exception as e:
        log.warning("&#91;경고] 휴장일 파일 로드 실패: %s", e); return out

# -------------------- ETF 판별 --------------------
ETF_KEYWORDS = &#91;"ETF", "KODEX", "TIGER", "ACE", "KBSTAR", "HANARO", "KOSEF", "SOL", "ARIRANG", "TREX"]

def _is_etf_name(name: str) -&gt; bool:
    up = name.upper(); return any(k in up for k in ETF_KEYWORDS)

def _is_etf_entry(name: str, stock_entry: Any) -&gt; bool:
    if isinstance(stock_entry, dict) and str(stock_entry.get("type","")).upper() == "ETF":
        return True
    return _is_etf_name(name)

# -------------------- 환율 처리 --------------------
def _parse_fx_pairs(pairs: List&#91;str]) -&gt; Dict&#91;str, float]:
    out = {}
    for p in pairs or &#91;]:
        if "=" not in p: continue
        k, v = p.split("=", 1)
        k = k.strip().upper()
        try: out&#91;k] = float(v.strip())
        except Exception: log.warning("&#91;경고] 환율 무시: %s", p)
    return out

def _get_fx(ccy: str, report_ccy: str) -&gt; float:
    ccy = (ccy or "KRW").upper(); report_ccy = (report_ccy or "KRW").upper()
    if ccy == report_ccy: return 1.0
    r_from = FX_RATES.get(ccy); r_to = FX_RATES.get(report_ccy)
    if not r_from or not r_to: return 1.0
    return r_from / r_to

def _collect_needed_currencies(report_ccy: str) -&gt; Set&#91;str]:
    need = {report_ccy.upper(), "KRW"}
    for _, entry in STOCKS.items():
        if isinstance(entry, dict):
            need.add(str(entry.get("currency","KRW")).upper())
        else:
            need.add("KRW")
    for v in DIVIDENDS.values():
        if isinstance(v, dict):
            need.add(str(v.get("currency","KRW")).upper())
    need |= _auto_fx_extra
    need = {c for c in need if c}
    return need

def _fetch_fx_exchangerate_host(symbols: List&#91;str], verify_ssl: bool) -&gt; Dict&#91;str, float]:
    url = "https://api.exchangerate.host/latest"
    sess = _get_session(verify_ssl)
    params = {"base":"KRW", "symbols": ",".join(sorted(set(&#91;c for c in symbols if c != "KRW"])))}  # KRW 제외
    r = sess.get(url, params=params, timeout=10)
    r.raise_for_status()
    j = r.json()
    rates = j.get("rates", {}) or {}
    out = {"KRW": 1.0}
    for c, v in rates.items():
        try:
            v = float(v)
            if v &gt; 0:
                out&#91;c.upper()] = 1.0 / v
        except Exception:
            pass
    return out

def _fetch_fx_frankfurter(symbols: List&#91;str], verify_ssl: bool) -&gt; Dict&#91;str, float]:
    url = "https://api.frankfurter.app/latest"
    sess = _get_session(verify_ssl)
    params = {"from":"KRW", "to": ",".join(sorted(set(&#91;c for c in symbols if c != "KRW"])))}  # KRW 제외
    r = sess.get(url, params=params, timeout=10)
    r.raise_for_status()
    j = r.json()
    rates = j.get("rates", {}) or {}
    out = {"KRW": 1.0}
    for c, v in rates.items():
        try:
            v = float(v)
            if v &gt; 0:
                out&#91;c.upper()] = 1.0 / v
        except Exception:
            pass
    return out

def _auto_fetch_and_update_fx(report_ccy: str, verify_ssl: bool):
    global _last_fx_fetch
    try:
        need = _collect_needed_currencies(report_ccy)
        if need == {"KRW"}:
            FX_RATES&#91;"KRW"] = 1.0
            _last_fx_fetch = datetime.now()
        try:
            log.info("&#91;FX] 갱신 완료 (report=%s)", report_ccy.upper())
            for k in sorted(FX_RATES.keys()):
                log.info("&#91;FX] %s = %.6f", k, float(FX_RATES&#91;k]))
        except Exception:
            pass
            log.info("자동 환율: 필요한 외화 없음(KRW만)")
            return

        got = {}
        try:
            got = _fetch_fx_exchangerate_host(list(need), verify_ssl)
            log.info("자동 환율(메인) 적용: %s", got)
        except Exception as e:
            log.warning("exchangerate.host 실패: %s → frankfurter.app 폴백 시도", e)
            got = _fetch_fx_frankfurter(list(need), verify_ssl)
            log.info("자동 환율(폴백) 적용: %s", got)

        FX_RATES.setdefault("KRW", 1.0)
        for k, v in got.items():
            if v and v &gt; 0:
                FX_RATES&#91;k.upper()] = float(v)
        FX_RATES.setdefault(report_ccy.upper(), FX_RATES.get(report_ccy.upper(), 1.0))
        _last_fx_fetch = datetime.now()
    except Exception as e:
        log.error("자동 환율 갱신 실패: %s", e)

def _maybe_refresh_fx(report_ccy: str, verify_ssl: bool):
    if not _auto_fx_enabled:
        return
    now = datetime.now()
    if (_last_fx_fetch is None) or ((now - _last_fx_fetch).total_seconds() &gt;= _fx_refresh_minutes * 60):
        _auto_fetch_and_update_fx(report_ccy, verify_ssl)

# -------------------- 상태 파일 --------------------
def _lock_file(f):
    if _HAS_FCNTL:
        try:
            fcntl.flock(f.fileno(), fcntl.LOCK_EX)
        except Exception:
            pass

def _unlock_file(f):
    if _HAS_FCNTL:
        try:
            fcntl.flock(f.fileno(), fcntl.LOCK_UN)
        except Exception:
            pass

def _load_state(path: str) -&gt; Dict&#91;str, Any]:
    if not path or not os.path.exists(path):
        return {"stock_alerts": {}, "total_alert": None}
    try:
        with open(path, "r", encoding="utf-8") as f:
            _lock_file(f)
            raw = f.read()
            _unlock_file(f)
        return json.loads(raw or "{}") or {"stock_alerts": {}, "total_alert": None}
    except Exception:
        return {"stock_alerts": {}, "total_alert": None}

def _save_state(path: str, state: Dict&#91;str, Any]):
    if not path: return
    try:
        os.makedirs(os.path.dirname(path), exist_ok=True)
    except Exception:
        pass
    try:
        tmp = path + ".tmp"
        with open(tmp, "w", encoding="utf-8") as f:
            _lock_file(f)
            json.dump(state, f, ensure_ascii=False, indent=2)
            f.flush()
            os.fsync(f.fileno())
            _unlock_file(f)
        os.replace(tmp, path)
    except Exception as e:
        log.warning("상태 파일 저장 실패: %s", e)

# -------------------- 히스토리 저장(JSONL) --------------------
def _history_path_for_month(history_dir: str, prefix: str, tz_name: str) -&gt; str:
    tz = _tzinfo(tz_name)
    now = datetime.now(tz)
    ym = now.strftime("%Y-%m")
    base = f"{prefix}_{ym}.jsonl"
    return os.path.join(history_dir, base)

def append_history_jsonl(rows, tz_name: str, report_ccy: str, history_dir: str, prefix: str = DEFAULT_HISTORY_PREFIX):
    """스냅샷(rows)을 JSONL로 누적 저장한다. (월간 CSV 리포트 생성용 원천데이터)"""
    if not history_dir:
        return
    try:
        os.makedirs(history_dir, exist_ok=True)
    except Exception:
        pass

    tz = _tzinfo(tz_name)
    ts = datetime.now(tz).isoformat(timespec="seconds")

    out = {"ts": ts, "tz": tz_name, "report_ccy": report_ccy, "items": &#91;]}

    for name, price, diff, pct, traded_at, ccy, entry in rows:
        pos = PORTFOLIO.get(name)
        invested, qty, avg_buy, lots_view = _pos_invested_qty_avg(pos) if pos else (0.0, 0, 0.0, &#91;])
        out&#91;"items"].append({
            "name": name,
            "code": _get_code(entry),
            "currency": ccy,
            "price": price,
            "diff": diff,
            "pct": pct,
            "traded_at": traded_at,
            "qty": qty,
            "avg_buy": avg_buy,
            "invested": invested,
            "lots": &#91;{"buy_price": b, "qty": q} for (b, q) in lots_view] if len(lots_view) &gt; 1 else &#91;]
        })

    path = _history_path_for_month(history_dir, prefix, tz_name)
    try:
        with open(path, "a", encoding="utf-8") as f:
            f.write(json.dumps(out, ensure_ascii=False) + "\n")
    except Exception as e:
        log.warning("히스토리 저장 실패(%s): %s", path, e)

# -------------------- 시세 조회 --------------------
def _get_code(entry: Any) -&gt; str:
    if isinstance(entry, dict): return str(entry.get("code","")).strip()
    return str(entry).strip()

def _get_currency(entry: Any) -&gt; str:
    if isinstance(entry, dict):
        c = str(entry.get("currency","KRW")).upper()
        return c if c else "KRW"
    return "KRW"

def fetch_price_from_naver(code: str, verify_ssl: bool, timeout=NAVER_TIMEOUT) -&gt; Tuple&#91;Optional&#91;float], Optional&#91;float], Optional&#91;float], Optional&#91;str]]:
    """네이버 응답 스키마 변동/오프장/빈 응답 견고화"""
    if not code or not isinstance(code, str):
        return None, None, None, None
    sess = _get_session(verify_ssl)
    try:
        r = sess.get(BASE.format(code=code), headers=HEADERS, timeout=timeout)
        r.raise_for_status()
    except Exception:
        # 실패 기록(쿨다운용) 후 상위에서 처리
        try:
            _failed_cache&#91;code] = time.time()
        except Exception:
            pass
        raise
    try:
        data = r.json()
    except Exception:
        try:
            _failed_cache&#91;code] = time.time()
        except Exception:
            pass
        return None, None, None, None

    def _normalize(d: dict) -&gt; Tuple&#91;Optional&#91;float], Optional&#91;float], Optional&#91;float], Optional&#91;str]]:
        last = _to_float(d.get("closePrice")) or _to_float(d.get("close"))
        diff = _to_float(d.get("compareToPreviousClosePrice")) or _to_float(d.get("diff"))
        pct  = _to_float(d.get("fluctuationsRatio")) or _to_float(d.get("rate"))
        date = d.get("localTradedAt") or d.get("localTradedTime") or d.get("time")
        return last, diff, pct, date

    if isinstance(data, list):
        if not data:
            return None, None, None, None
        # 최신이 0번 인덱스로 오는 케이스
        return _normalize(data&#91;0] or {})

    if isinstance(data, dict):
        # 스키마 케이스 1: {closePrice:{price}, prevClosePrice:{price}}
        if "closePrice" in data or "prevClosePrice" in data:
            try:
                last = _to_float(data.get("closePrice", {}).get("price"))
                prev = _to_float(data.get("prevClosePrice", {}).get("price"))
                diff = (last - prev) if (last is not None and prev is not None) else None
                pct  = (diff / prev * 100) if (diff is not None and prev not in (None, 0)) else None
                date = data.get("localTradedAt")
                return last, diff, pct, date
            except Exception:
                pass
        # 스키마 케이스 2: 낙관 파싱
        return _normalize(data)

    return None, None, None, None

def snapshot_all(verify_ssl: bool) -&gt; List&#91;Tuple&#91;str, Optional&#91;float], Optional&#91;float], Optional&#91;float], Optional&#91;str], str, Any]]:
    rows = &#91;]
    for name, entry in STOCKS.items():
        code = _get_code(entry).strip()
        ccy = _get_currency(entry)
        if not code:
            log.warning("종목 '%s' 코드가 비어 있어 스킵합니다.", name)
            rows.append((name, None, None, None, None, ccy, entry))
            continue
        try:
            price, diff, pct, date = fetch_price_from_naver(code, verify_ssl)
        except Exception as e:
            log.warning("네이버 조회 실패 %s(%s): %s", name, code, e)
            price = diff = pct = date = None
        rows.append((name, price, diff, pct, date, ccy, entry))

    # 포트폴리오에 있으나 종목 목록에 없는 키 알림(오타/누락 방지)
    missing = set(PORTFOLIO.keys()) - set(STOCKS.keys())
    if missing:
        log.warning("포트폴리오에 있으나 STOCKS에 없는 항목: %s", ", ".join(sorted(missing)))
    return rows

# -------------------- 포트폴리오/배당 계산 --------------------
def _pos_invested_qty_avg(pos) -&gt; Tuple&#91;float, int, float, List&#91;Tuple&#91;float, int]]]:
    if not pos: return 0.0, 0, 0.0, &#91;]
    if "lots" in pos and isinstance(pos&#91;"lots"], list):
        invested = 0.0; qty = 0; lots_view = &#91;]
        for lot in pos&#91;"lots"]:
            b = float(lot.get("buy_price", 0) or 0); q = int(lot.get("qty", 0) or 0)
            if q &lt;= 0: continue
            invested += b * q; qty += q; lots_view.append((b, q))
        avg = (invested / qty) if qty else 0.0
        return invested, qty, avg, lots_view
    buy = float(pos.get("buy_price", 0) or 0); qty = int(pos.get("qty", 0) or 0)
    invested = buy * qty
    return invested, qty, buy, &#91;(buy, qty)] if qty &gt; 0 else &#91;]

def _div_total_for(name: str) -&gt; Tuple&#91;float, str]:
    d = DIVIDENDS.get(name)
    if not d: return 0.0, "KRW"
    items = d.get("items"); ccy = str(d.get("currency","KRW")).upper()
    if not items or not isinstance(items, list): return 0.0, ccy
    total = 0.0
    for it in items:
        if "total" in it:
            v = _to_float(it.get("total")); total += v if v else 0.0; continue
        per = _to_float(it.get("per_share")); q = _to_float(it.get("qty"))
        if per and q: total += per * q
    return total, ccy

# ==================== 수수료/세금(순손익) 유틸 ====================
def _calc_commission(value: float) -&gt; float:
    if value &lt;= 0: return 0.0
    return float(value) * float(_commission_rate)

def _calc_sell_tax(value: float, is_etf: bool) -&gt; float:
    rate = _sell_tax_rate_etf if is_etf else _sell_tax_rate_stock
    if value &lt;= 0 or rate &lt;= 0: return 0.0
    return float(value) * float(rate)

def _estimate_net_pl(invested_r: float, market_r: float, is_etf: bool) -&gt; Tuple&#91;float, float]:
    buy_commission = _calc_commission(invested_r) if _include_buy_commission_in_cost else 0.0
    basis_r = invested_r + buy_commission
    sell_commission = _calc_commission(market_r)
    sell_tax = _calc_sell_tax(market_r, is_etf)
    net_proceeds = market_r - sell_commission - sell_tax
    net_pl = net_proceeds - basis_r
    net_roi = (net_pl / basis_r * 100) if basis_r &gt; 0 else 0.0
    return net_pl, net_roi

def _safe_roi(pl: float, invested: float) -&gt; float:
    return (pl / invested * 100.0) if invested and invested != 0 else 0.0

# -------------------- 메시지 포맷 --------------------
def format_chat_message(rows, tz_name: str, report_ccy: str, mention: str = "", title_prefix: str = "", compact: bool = False) -&gt; str:
    tz = _tzinfo(tz_name); ts = datetime.now(tz).strftime("%Y-%m-%d %H:%M")
    title = f"&#91;네이버 주가 스냅샷] {ts} (보유손익)"
    if title_prefix: title = f"{title_prefix} {title}"
    head = f"{mention}{title}" if mention else title
    lines = &#91;head]

    total_invested_r = 0.0; total_market_r = 0.0; total_div_r = 0.0

    for name, price, diff, pct, date, ccy, entry in rows:
        if price is None:
            lines.append(f"- {name}: 데이터 없음"); continue

        pos = PORTFOLIO.get(name)
        invested, qty, avg_buy, lots_view = _pos_invested_qty_avg(pos) if pos else (0.0, 0, 0.0, &#91;])

        fx = _get_fx(ccy, report_ccy)
        price_r = (price or 0) * fx
        invested_r = invested * fx
        market_r   = (price or 0) * qty * fx

        div_sum, div_ccy = _div_total_for(name)
        fx_div = _get_fx(div_ccy, report_ccy); div_r = div_sum * fx_div

        pl_r  = market_r - invested_r
        roi   = _safe_roi(pl_r, invested_r)

        # 순손익(수수료/세금 반영)
        is_etf = _is_etf_entry(name, entry)
        net_pl_r, net_roi = _estimate_net_pl(invested_r, market_r, is_etf)

        total_invested_r += invested_r; total_market_r += market_r; total_div_r += div_r

        sign = "▲" if (diff is not None and diff &gt; 0) else ("▼" if (diff is not None and diff &lt; 0) else "-")
        diff_txt = f"{abs(diff):,.0f}" if diff is not None else "N/A"
        pct_txt  = f"{pct:.2f}%" if pct is not None else "N/A"

        lots_note = ""
        if len(lots_view) &gt; 1:
            lots_note = " | lots " + "; ".join(&#91;f"{int(b):,}×{q}" for b, q in lots_view])

        date_txt = ""  # 필요 시 표시 확장 가능(localTradedAt 등)

        unit = "원" if report_ccy == "KRW" else f" {report_ccy}"
        if qty &gt; 0:
            if compact:
                lines.append(
                    f"- {name}\n"
                    f"  현재가 {price_r:,.0f}{unit} {sign}{diff_txt} ({pct_txt})\n"
                    f"  매입가(평단) {avg_buy * fx:,.0f}{unit} × {qty}주{(' (분할매수 %d회)' % len(lots_view)) if len(lots_view) &gt; 1 else ''}\n"
                    f"  투자 {_fmt_money(invested_r, report_ccy)} → 평가 {_fmt_money(market_r, report_ccy)}\n"
                    f"  손익 {_fmt_signed(pl_r, report_ccy)} ({_fmt_signed_pct(roi)}) | 순손익 {_fmt_signed(net_pl_r, report_ccy)} ({_fmt_signed_pct(net_roi)})"
                    f"{f' | 분배금 {_fmt_money(div_r, report_ccy)}' if round(div_r) &gt; 0 else ''}"
                    f"{lots_note}{date_txt}"
                )
            else:
                lines.append(
                    f"- {name}: {price_r:,.0f}{unit} "
                    f"{sign}{diff_txt} ({pct_txt})"
                    f" | 매입가(평단) {avg_buy * fx:,.0f}{unit} × {qty}주"
                    f" | 투자원금 {_fmt_money(invested_r, report_ccy)}"
                    f" | 평가금액 {_fmt_money(market_r, report_ccy)}"
                    f" | 평가손익 {_fmt_signed(pl_r, report_ccy)} ({_fmt_signed_pct(roi)})"
                    f" | 순손익(수수료/세금) {_fmt_signed(net_pl_r, report_ccy)} ({_fmt_signed_pct(net_roi)})"
                    f"{f' | 분배금 누계 {_fmt_money(div_r, report_ccy)}' if round(div_r) &gt; 0 else ''}"
                    f"{lots_note}{date_txt}"
                )
        else:
            if compact:
                lines.append(
                    f"- {name}: {price_r:,.0f}{unit} {sign}{diff_txt} ({pct_txt})"
                    f"{f' | (미보유) 분배금 {_fmt_money(div_r, report_ccy)}' if round(div_r) &gt; 0 else ''}{date_txt}"
                )
            else:
                lines.append(
                    f"- {name}: {price_r:,.0f}{unit} "
                    f"{sign}{diff_txt} ({pct_txt})"
                    f"{f' | (미보유) 분배금 누계 {_fmt_money(div_r, report_ccy)}' if round(div_r) &gt; 0 else ''}{date_txt}"
                )

    if total_invested_r &gt; 0:
        total_pl_r  = total_market_r - total_invested_r
        total_roi   = _safe_roi(total_pl_r, total_invested_r)
        lines.append("=" * 110)
        lines.append(f"&#91;합계] 투자원금 {_fmt_money(total_invested_r, report_ccy)} / 평가금액 {_fmt_money(total_market_r, report_ccy)}")
        lines.append(f"&#91;합계] 평가손익 {_fmt_signed(total_pl_r, report_ccy)} ({_fmt_signed_pct(total_roi)})")
        if round(total_div_r) &gt; 0:
            lines.append(f"&#91;합계] 분배금 누계 {_fmt_money(total_div_r, report_ccy)}")

        # 합계 순손익(수수료/세금 반영)
        net_pl_all, net_roi_all, _ = _calc_totals_net(rows, report_ccy)
        lines.append(f"&#91;합계] 순손익(수수료/세금) {_fmt_signed(net_pl_all, report_ccy)} ({_fmt_signed_pct(net_roi_all)})")

    return "\n".join(lines)

# -------------------- 합계 계산(기존/순손익) --------------------
def _calc_totals(rows, report_ccy: str) -&gt; Tuple&#91;float, float, float]:
    total_invested_r = 0.0; total_market_r = 0.0
    for name, price, diff, pct, date, ccy, entry in rows:
        if price is None: continue
        pos = PORTFOLIO.get(name)
        if not pos: continue
        invested, qty, _, _ = _pos_invested_qty_avg(pos)
        fx = _get_fx(ccy, report_ccy)
        total_invested_r += invested * fx
        total_market_r   += (price or 0) * qty * fx
    total_pl_r = total_market_r - total_invested_r
    total_roi = _safe_roi(total_pl_r, total_invested_r)
    return total_pl_r, total_roi, total_invested_r

def _calc_totals_net(rows, report_ccy: str) -&gt; Tuple&#91;float, float, float]:
    total_basis_r = 0.0
    total_net_proceeds = 0.0
    for name, price, diff, pct, date, ccy, entry in rows:
        if price is None:
            continue
        pos = PORTFOLIO.get(name)
        if not pos:
            continue
        invested, qty, _, _ = _pos_invested_qty_avg(pos)
        fx = _get_fx(ccy, report_ccy)
        invested_r = invested * fx
        market_r   = (price or 0) * qty * fx

        is_etf = _is_etf_entry(name, entry)
        buy_commission = _calc_commission(invested_r) if _include_buy_commission_in_cost else 0.0
        basis_r = invested_r + buy_commission
        sell_commission = _calc_commission(market_r)
        sell_tax = _calc_sell_tax(market_r, is_etf)

        total_basis_r += basis_r
        total_net_proceeds += max(0.0, market_r - sell_commission - sell_tax)

    net_pl = total_net_proceeds - total_basis_r
    net_roi = _safe_roi(net_pl, total_basis_r)
    return net_pl, net_roi, total_basis_r

# -------------------- 웹훅 전송 --------------------
def _respect_min_gap():
    global _last_send_ts
    if _min_send_gap_sec &lt;= 0:
        return
    now = time.time()
    if _last_send_ts is not None:
        gap = now - _last_send_ts
        if gap &lt; _min_send_gap_sec:
            _sleep_with_stop(_min_send_gap_sec - gap)
    _last_send_ts = time.time()

def _post_webhook(url: str, payload_text: str, verify_ssl: bool):
    """Synology Chat Webhook 전송 (411은 Retry-After/백오프+지터 후 1회 재시도)"""
    sess = _get_session(verify_ssl)

    def _send(as_what: str) -&gt; requests.Response:
        _respect_min_gap()
        if as_what == "payload":
            data = {"payload": json.dumps({"text": payload_text}, ensure_ascii=False)}
            return sess.post(url, data=data, headers={"Content-Type":"application/x-www-form-urlencoded; charset=utf-8"}, timeout=10)
        elif as_what == "json":
            return sess.post(url, json={"text": payload_text}, timeout=10)
        else:
            data = {"text": payload_text}
            return sess.post(url, data=data, headers={"Content-Type":"application/x-www-form-urlencoded; charset=utf-8"}, timeout=10)

    def ok_resp(r: requests.Response, j: dict) -&gt; bool:
        # 200/204 허용, JSON이면 success=true류도 허용
        if r.status_code in (200, 204):
            if not j:
                return True
            return j.get("success") in (True, "true", "OK", "ok", None)
        return False

    def try_send_once(as_what: str) -&gt; Tuple&#91;bool, str]:
        r = _send(as_what)
        try:
            ct = r.headers.get("Content-Type","")
            j = r.json() if "json" in ct.lower() else {}
        except Exception:
            j = {}
        if ok_resp(r, j):
            return True, "ok"

        if r.status_code == 411:
            try:
                ra = r.headers.get("Retry-After")
                wait = float(ra) if ra is not None else 3.0
            except Exception:
                wait = 3.0
            wait += random.uniform(0, 1.0)  # 지터
            _sleep_with_stop(min(max(wait, 1.0), 10.0))
            r2 = _send(as_what)
            try:
                ct2 = r2.headers.get("Content-Type","")
                j2 = r2.json() if "json" in ct2.lower() else {}
            except Exception:
                j2 = {}
            if ok_resp(r2, j2):
                return True, "ok"
            _log_http(r2, label=f"webhook-retry-{as_what}")
            return False, f"rate-411-retry-fail:{r2.status_code}"

        _log_http(r, label=f"webhook-{as_what}")
        return False, f"http-{r.status_code}"

    for as_what in ("payload", "json", "text"):
        try:
            ok, reason = try_send_once(as_what)
        except Exception as e:
            log.warning("&#91;WARN] webhook %s send exception: %s", as_what, e)
            ok, reason = False, "exception"
        if ok:
            return True

    raise RuntimeError("전송 실패: 레이트 리밋/권한/포맷/인증서 등을 확인하세요.")

# -------------------- 전송 크기 분할 --------------------
def _split_text_chunks(text: str, limit: int = 4000, reserve_suffix: int = 24) -&gt; list:
    """
    텍스트를 limit자 이하로 분할하되, 뒤에 붙일 꼬리표 길이(reserve_suffix)를 미리 뺀다.
    - 가능한 한 줄 단위로 자름(\n 경계 우선, 60% 이상 채웠을 때)
    - 줄이 너무 길면 하드 컷
    """
    if text is None:
        return &#91;""]
    s = str(text)
    eff_limit = max(200, limit - max(0, reserve_suffix))
    if len(s) &lt;= eff_limit:
        return &#91;s]

    parts = &#91;]
    start = 0
    while start &lt; len(s):
        end = min(len(s), start + eff_limit)
        if end &lt; len(s):
            nl = s.rfind("\n", start, end)
            if nl != -1 and (nl - start) &gt;= int(eff_limit * 0.6):
                end = nl + 1
        parts.append(s&#91;start:end])
        start = end
    return parts

def send_to_synology_chat(message: str, verify_ssl: bool):
    if not CHAT_WEBHOOK_URL:
        raise RuntimeError("CHAT_WEBHOOK_URL이 비어 있습니다. 환경변수로 설정하세요.")
    parts = _split_text_chunks(message, 4000, reserve_suffix=24)
    total = len(parts)
    for i, part in enumerate(parts, 1):
        suffix = "" if total == 1 else f"\n(part {i}/{total})"
        _post_webhook(CHAT_WEBHOOK_URL, part + suffix, verify_ssl)

def send_error_alert(message: str, verify_ssl: bool):
    _sleep_with_stop(2)
    if CHAT_ERROR_WEBHOOK_URL:
        try:
            _post_webhook(CHAT_ERROR_WEBHOOK_URL, f"&#91;에러 알림]\n{message}", verify_ssl)
        except Exception as e:
            log.error("에러 웹훅 전송 실패: %s", e)

# -------------------- 임계치(알람) --------------------
def _check_stock_alerts(rows, alert_pl_pct, alert_pl_abs, state, report_ccy, only_etf=False) -&gt; List&#91;str]:
    res = &#91;]; state.setdefault("stock_alerts", {})
    for name, price, diff, pct, date, ccy, entry in rows:
        if price is None: continue
        if only_etf and not _is_etf_entry(name, entry): continue
        pos = PORTFOLIO.get(name)
        if not pos: continue
        invested, qty, _, _ = _pos_invested_qty_avg(pos)
        fx = _get_fx(ccy, report_ccy)
        market_r = (price or 0) * qty * fx
        invested_r = invested * fx
        pl_r = market_r - invested_r
        roi  = _safe_roi(pl_r, invested_r)
        need_alert = False; reason=&#91;]
        if alert_pl_pct is not None and abs(roi) &gt;= alert_pl_pct:
            need_alert=True; reason.append(f"수익률 {roi:.2f}% (임계 {alert_pl_pct}%)")
        if alert_pl_abs is not None and abs(pl_r) &gt;= alert_pl_abs:
            need_alert=True; reason.append(f"손익 {pl_r:,.0f}{'원' if report_ccy=='KRW' else ' ' + report_ccy} (임계 {alert_pl_abs:,.0f}{'원' if report_ccy=='KRW' else ' ' + report_ccy})")
        if not need_alert: continue
        last = state&#91;"stock_alerts"].get(name, {})
        # 쿨다운: 같은 종목이 임계 상태를 유지할 때 알림 스팸 방지
        try:
            if last.get("ts") and (time.time() - float(last.get("ts"))) &lt; ALERT_COOLDOWN_SEC:
                continue
        except Exception:
            pass
        last_pl = last.get("pl")
        if last_pl is None or (pl_r &gt;= 0) != (last_pl &gt;= 0):
            res.append(f"📣 &#91;종목 임계] {name}: {_fmt_signed(pl_r, report_ccy)} ({_fmt_signed_pct(roi)})")
            state&#91;"stock_alerts"]&#91;name] = {"pl": pl_r, "roi": roi, "ts": time.time()}
        else:
            was_under = True
            if alert_pl_pct is not None and last.get("roi") is not None:
                was_under &amp;= (abs(float(last&#91;"roi"])) &lt; alert_pl_pct)
            if alert_pl_abs is not None and last.get("pl") is not None:
                was_under &amp;= (abs(float(last&#91;"pl"])) &lt; alert_pl_abs)
            if was_under:
                res.append(f"📣 &#91;종목 임계] {name}: {_fmt_signed(pl_r, report_ccy)} ({_fmt_signed_pct(roi)})")
                state&#91;"stock_alerts"]&#91;name] = {"pl": pl_r, "roi": roi, "ts": time.time()}
    return res

def _check_total_alert(rows, alert_total_pl_pct, alert_total_pl_abs, state, report_ccy, only_etf=False) -&gt; Optional&#91;str]:
    state.setdefault("total_alert", None)
    fil_rows = rows if not only_etf else &#91;(n,p,d,pc,dt,ccy,e) for (n,p,d,pc,dt,ccy,e) in rows if _is_etf_entry(n,e)]
    total_pl_r, total_roi, total_invested_r = _calc_totals(fil_rows, report_ccy)
    need_alert=False; reasons=&#91;]
    if alert_total_pl_pct is not None and abs(total_roi) &gt;= alert_total_pl_pct:
        need_alert=True; reasons.append(f"합계 수익률 {total_roi:.2f}% (임계 {alert_total_pl_pct}%)")
    if alert_total_pl_abs is not None and abs(total_pl_r) &gt;= alert_total_pl_abs:
        need_alert=True; reasons.append(f"합계 손익 {total_pl_r:,.0f}{'원' if report_ccy=='KRW' else ' ' + report_ccy} (임계 {alert_total_pl_abs:,.0f}{'원' if report_ccy=='KRW' else ' ' + report_ccy})")
    if not need_alert: return None
    last = state.get("total_alert")
    # 쿨다운: 합계 알림 스팸 방지
    try:
        if last and last.get("ts") and (time.time() - float(last.get("ts"))) &lt; ALERT_COOLDOWN_SEC:
            return None
    except Exception:
        pass
    if last is None or (total_pl_r &gt;= 0) != (float(last.get("pl", 0)) &gt;= 0):
        state&#91;"total_alert"] = {"pl": total_pl_r, "roi": total_roi, "ts": time.time()}
        return f"🚨 &#91;합계 임계] 평가손익 {_fmt_signed(total_pl_r, report_ccy)} ({_fmt_signed_pct(total_roi)}) | 투자원금 {_fmt_money(total_invested_r, report_ccy)} | 사유: {', '.join(reasons)}"
    was_under=True
    if alert_total_pl_pct is not None and last and last.get("roi") is not None:
        was_under &amp;= (abs(float(last&#91;"roi"])) &lt; alert_total_pl_pct)
    if alert_total_pl_abs is not None and last and last.get("pl") is not None:
        was_under &amp;= (abs(float(last&#91;"pl"])) &lt; alert_total_pl_abs)
    if was_under:
        state&#91;"total_alert"] = {"pl": total_pl_r, "roi": total_roi, "ts": time.time()}
        return f"🚨 &#91;합계 임계] 평가손익 {_fmt_signed(total_pl_r, report_ccy)} ({_fmt_signed_pct(total_roi)}) | 사유: {', '.join(reasons)}"
    return None

# -------------------- 슬립(중단가능) --------------------
def _sleep_with_stop(seconds: float):
    global _stop
    end = time.time() + float(seconds)
    try:
        while not _stop and time.time() &lt; end:
            time.sleep(min(1.0, end - time.time()))
    except KeyboardInterrupt:
        _stop = True

# -------------------- 실행 루틴 --------------------
def run_once(verify_ssl: bool, tz_name: str, report_ccy: str, mention: str = "", title_prefix: str = "", compact: bool = False, save_history: bool = False, history_dir: str = DEFAULT_HISTORY_DIR, history_prefix: str = DEFAULT_HISTORY_PREFIX):
    _maybe_refresh_fx(report_ccy, verify_ssl)
    rows = snapshot_all(verify_ssl)
    if save_history:
        append_history_jsonl(rows, tz_name=tz_name, report_ccy=report_ccy, history_dir=history_dir, prefix=history_prefix)
    msg = format_chat_message(rows, tz_name=tz_name, report_ccy=report_ccy, mention=mention, title_prefix=title_prefix, compact=compact)
    print(msg)
    send_to_synology_chat(msg, verify_ssl)

def run_daemon(interval_sec: int, verify_ssl: bool, tz_name: str, report_ccy: str,
               mention: str = "", title_prefix: str = "",
               compact: bool = False,
               save_history: bool = False,
               history_dir: str = DEFAULT_HISTORY_DIR,
               history_prefix: str = DEFAULT_HISTORY_PREFIX,
               alerts_only: bool = False,
               alert_pl_pct: Optional&#91;float] = None, alert_pl_abs: Optional&#91;float] = None,
               alert_total_pl_pct: Optional&#91;float] = None, alert_total_pl_abs: Optional&#91;float] = None,
               state_file: str = "",
               alerts_etf_only: bool = False, alerts_total_etf_only: bool = False):
    state = _load_state(state_file); next_send = datetime.min.replace(tzinfo=None)
    while not _stop:
        now = datetime.now()
        if now &gt;= next_send:
            try:
                _maybe_refresh_fx(report_ccy, verify_ssl)
                rows = snapshot_all(verify_ssl)
                if save_history:
                    append_history_jsonl(rows, tz_name=tz_name, report_ccy=report_ccy, history_dir=history_dir, prefix=history_prefix)
                if save_history:
                    append_history_jsonl(rows, tz_name=tz_name, report_ccy=report_ccy, history_dir=history_dir, prefix=history_prefix)
                stock_alerts = _check_stock_alerts(rows, alert_pl_pct, alert_pl_abs, state, report_ccy, only_etf=alerts_etf_only)
                total_alert  = _check_total_alert(rows, alert_total_pl_pct, alert_total_pl_abs, state, report_ccy, only_etf=alerts_total_etf_only)
                if stock_alerts or total_alert:
                    alert_text = "\n".join((&#91;total_alert] if total_alert else &#91;]) + stock_alerts)
                    try: send_to_synology_chat(alert_text, verify_ssl)
                    except Exception as e: log.error("&#91;에러] 임계 알림 전송 실패: %s", e); send_error_alert(str(e), verify_ssl)
                if not alerts_only:
                    msg = format_chat_message(rows, tz_name=tz_name, report_ccy=report_ccy, mention=mention, title_prefix=title_prefix, compact=compact)
                    print(msg); send_to_synology_chat(msg, verify_ssl)
                _save_state(state_file, state)
            except Exception as e:
                log.error("&#91;에러] 데몬 루프 오류: %s", e); send_error_alert(str(e), verify_ssl)
            next_send = now + timedelta(seconds=interval_sec)
        _sleep_with_stop(5)

def run_market_hours(start_str: str, end_str: str, every_min: int, verify_ssl: bool, tz_name: str, report_ccy: str,
                     weekdays_only: bool = True, holidays: Optional&#91;Set&#91;ddate]] = None,
                     mention: str = "", title_prefix: str = "",
                     compact: bool = False,
                     save_history: bool = False,
                     history_dir: str = DEFAULT_HISTORY_DIR,
                     history_prefix: str = DEFAULT_HISTORY_PREFIX,
                     alerts_only: bool = False,
                     alert_pl_pct: Optional&#91;float] = None, alert_pl_abs: Optional&#91;float] = None,
                     alert_total_pl_pct: Optional&#91;float] = None, alert_total_pl_abs: Optional&#91;float] = None,
                     state_file: str = "",
                     alerts_etf_only: bool = False, alerts_total_etf_only: bool = False):
    start_t = _parse_hhmm(start_str); end_t = _parse_hhmm(end_str)
    holidays = holidays or set(); tz = _tzinfo(tz_name); state = _load_state(state_file)

    while not _stop:
        now = datetime.now(tz); today = now.date()
        is_weekend = not _is_weekday(now); is_holiday = today in holidays

        if (weekdays_only and is_weekend) or is_holiday:
            days = 1; nxt = today + timedelta(days=days)
            while (weekdays_only and nxt.weekday() &gt;= 5) or (nxt in holidays): days += 1; nxt = today + timedelta(days=days)
            next_start = datetime.combine(nxt, start_t, tzinfo=tz)
            sleep_sec = max(5, int((next_start - now).total_seconds()))
            log.info("&#91;대기] %s → %s 까지 %s초 대기", "휴장일" if is_holiday else "주말 제외", next_start, sleep_sec)
            _sleep_with_stop(sleep_sec); continue

        if _within(now, start_t, end_t):
            next_run = _ceil_to_next_minutes(now, every_min)
            if not _within(next_run, start_t, end_t):
                days = 1; nxt = today + timedelta(days=days)
                while (weekdays_only and nxt.weekday() &gt;= 5) or (nxt in holidays): days += 1; nxt = today + timedelta(days=days)
                next_start = datetime.combine(nxt, start_t, tzinfo=tz)
                sleep_sec = max(5, int((next_start - now).total_seconds()))
                log.info("&#91;대기] 창 종료 → 다음 영업일 시작 %s 까지 %s초 대기", next_start, sleep_sec)
                _sleep_with_stop(sleep_sec); continue

            try:
                _maybe_refresh_fx(report_ccy, verify_ssl)
                rows = snapshot_all(verify_ssl)
                stock_alerts = _check_stock_alerts(rows, alert_pl_pct, alert_pl_abs, state, report_ccy, only_etf=alerts_etf_only)
                total_alert  = _check_total_alert(rows, alert_total_pl_pct, alert_total_pl_abs, state, report_ccy, only_etf=alerts_total_etf_only)
                if stock_alerts or total_alert:
                    alert_text = "\n".join((&#91;total_alert] if total_alert else &#91;]) + stock_alerts)
                    try: send_to_synology_chat(alert_text, verify_ssl)
                    except Exception as e: log.error("&#91;에러] 임계 알림 전송 실패: %s", e); send_error_alert(str(e), verify_ssl)
                if not alerts_only:
                    msg = format_chat_message(rows, tz_name=tz_name, report_ccy=report_ccy, mention=mention, title_prefix=title_prefix, compact=compact)
                    print(msg); send_to_synology_chat(msg, verify_ssl)
                _save_state(state_file, state)
            except Exception as e:
                log.error("&#91;에러] 전송 중 오류: %s", e); send_error_alert(str(e), verify_ssl)

            now2 = datetime.now(tz)
            sleep_sec = max(5, int((next_run - now2).total_seconds()))
            log.info("&#91;대기] 다음 실행 %s 까지 %s초 대기", next_run, sleep_sec)
            _sleep_with_stop(sleep_sec)
        else:
            target_dt = datetime.combine(today, start_t, tzinfo=tz)
            if now &gt;= target_dt:
                days = 1; nxt = today + timedelta(days=days)
                while (weekdays_only and nxt.weekday() &gt;= 5) or (nxt in holidays): days += 1; nxt = today + timedelta(days=days)
                target_dt = datetime.combine(nxt, start_t, tzinfo=tz)
            sleep_sec = max(5, int((target_dt - now).total_seconds()))
            log.info("&#91;대기] 시간 창 외 → %s 까지 %s초 대기", target_dt, sleep_sec)
            _sleep_with_stop(sleep_sec)


# -------------------- PID 락(단일 인스턴스) --------------------
_pid_file_path: str = ""

def _acquire_pid_lock(pid_file: str):
    """pid_file이 지정되면 단일 인스턴스 실행을 보장한다."""
    global _pid_file_path
    if not pid_file:
        return
    try:
        # 디렉터리 생성(가능하면)
        d = os.path.dirname(pid_file)
        if d:
            os.makedirs(d, exist_ok=True)
    except Exception:
        pass

    # 이미 존재 + PID가 살아있으면 중복 실행으로 판단
    try:
        if os.path.exists(pid_file):
            with open(pid_file, "r", encoding="utf-8") as f:
                old = (f.read() or "").strip()
            if old.isdigit():
                old_pid = int(old)
                try:
                    os.kill(old_pid, 0)  # 프로세스 존재 여부 확인
                    raise RuntimeError(f"이미 실행 중입니다(pid={old_pid}). pid_file={pid_file}")
                except ProcessLookupError:
                    # 죽은 PID → 덮어씀
                    pass
                except PermissionError:
                    # 권한으로 확인 불가 → 보수적으로 중복 실행 방지
                    raise RuntimeError(f"PID 확인 권한이 없어 중복 실행을 막습니다. pid_file={pid_file}")
    except RuntimeError:
        raise
    except Exception:
        # pid 읽기 실패는 덮어쓰기 허용
        pass

    try:
        with open(pid_file, "w", encoding="utf-8") as f:
            f.write(str(os.getpid()))
        _pid_file_path = pid_file
        atexit.register(_release_pid_lock)
        log.info("&#91;PID] lock acquired: %s", pid_file)
    except Exception as e:
        raise RuntimeError(f"PID 파일 생성 실패: {pid_file} ({e})")

def _release_pid_lock():
    global _pid_file_path
    if not _pid_file_path:
        return
    try:
        if os.path.exists(_pid_file_path):
            os.remove(_pid_file_path)
            log.info("&#91;PID] lock released: %s", _pid_file_path)
    except Exception:
        pass
    _pid_file_path = ""


# -------------------- main --------------------
def main():
    global VERIFY_SSL, STOCKS, PORTFOLIO, DIVIDENDS, FX_RATES
    global _auto_fx_enabled, _auto_fx_extra, _fx_refresh_minutes, _last_fx_fetch
    global _commission_rate, _sell_tax_rate_stock, _sell_tax_rate_etf, _include_buy_commission_in_cost

    _install_signal_handlers()

    p = argparse.ArgumentParser(description="Synology Chat으로 네이버 주가/ETF 알림 (배당/환율/ETF필터+자동환율+수수료/세금)")
    p.add_argument("--daemon", action="store_true", help="지정 간격으로 반복 전송")
    p.add_argument("--interval", type=int, default=DEFAULT_INTERVAL, help="반복 전송 간격(초), 기본 1200")
    p.add_argument("--insecure", action="store_true", help="SSL 검증 비활성화(자가서명 인증서일 때)")
    # 장중
    p.add_argument("--market-hours", action="store_true", help="장시간 내 주기 전송")
    p.add_argument("--start", default="09:00", help="--market-hours 시작 시각(HH:MM)")
    p.add_argument("--end", default="15:30", help="--market-hours 종료 시각(HH:MM)")
    p.add_argument("--every-min", type=int, default=10, help="--market-hours 전송 주기(분)")
    p.add_argument("--no-weekdays-only", action="store_true", help="주말에도 전송하려면 지정")
    p.add_argument("--tz", default=DEFAULT_TZ, help="시간대(기본 Asia/Seoul)")
    # 휴장일
    p.add_argument("--holiday", action="append", default=&#91;], help="휴장일 YYYY-MM-DD (여러 번 지정)")
    p.add_argument("--holiday-file", default="", help="휴장일 파일(JSON/CSV/텍스트)")
    # 외부 데이터
    p.add_argument("--stocks-file", default="", help="종목 코드/속성 JSON {'이름':'코드'} or {'이름':{'code','type','currency'}}")
    p.add_argument("--portfolio-file", default="", help="포트폴리오 JSON {'이름': {'buy_price,qty'} or lots}")
    p.add_argument("--dividends-file", default="", help="분배금 JSON {'이름': {'currency','items':&#91;...] } }")
    p.add_argument("--fx-file", default="", help="환율 JSON {'USD':1350,'KRW':1,...}")
    # 환율 수동/자동
    p.add_argument("--fx", action="append", default=&#91;], help="수동 환율(예: USD=1350). 여러번 지정 가능")
    p.add_argument("--report-ccy", default=DEFAULT_REPORT_CCY, help="리포트 통화(기본 KRW)")
    p.add_argument("--auto-fx", action="store_true", help="자동으로 환율 불러오기")
    p.add_argument("--auto-fx-extra", default="", help="자동 환율에 추가할 통화(쉼표구분)")
    p.add_argument("--fx-refresh-min", type=int, default=720, help="자동 환율 갱신 주기(분)")
    # 메시지
    p.add_argument("--mention", default="", help="메시지 상단 멘션/텍스트")
    p.add_argument("--title-prefix", default="", help="제목 접두어")
    p.add_argument("--compact", action="store_true", help="메시지를 2~3줄 요약 형태로 출력(가독성 향상)")
    # 히스토리(월간 CSV 리포트 원천)
    p.add_argument("--save-history", action="store_true", help="스냅샷을 JSONL로 저장(월간 리포트용)")
    p.add_argument("--history-dir", default=DEFAULT_HISTORY_DIR, help="히스토리 저장 폴더")
    p.add_argument("--history-prefix", default=DEFAULT_HISTORY_PREFIX, help="히스토리 파일 접두어")
    # 수수료/세금 옵션 (bps 입력)
    p.add_argument("--commission-bps", type=float, default=None,
                   help="커미션(bp). 예: 1.5bp=0.015%%는 1.5. 지정하지 않으면 --venue에 따라 기본값 적용")
    p.add_argument("--sell-tax-bps-stock", type=float, default=0.0, help="주식 매도 거래세(bp). 예: 20bp=0.20%%는 20")
    p.add_argument("--sell-tax-bps-etf", type=float, default=0.0, help="ETF 매도 거래세(bp). 보통 0")
    p.add_argument("--no-buy-commission-into-cost", action="store_true",
                   help="매수 커미션을 취득원가에 포함하지 않음(기본 포함)")
    p.add_argument("--venue", choices=&#91;"krx","nxt"], default="krx",
                   help="기본 커미션 적용 시장: krx=0.015%%, nxt=0.0145%% (commission-bps 미지정 시에만 적용)")
    # 임계치 알람
    p.add_argument("--alert-pl-pct", type=float, default=None, help="종목별 손익률 임계치(절대값, %)")
    p.add_argument("--alert-pl-abs", type=float, default=None, help="종목별 손익액 임계치(절대값, 리포트통화)")
    p.add_argument("--alert-total-pl-pct", type=float, default=None, help="합계 손익률 임계치(절대값, %)")
    p.add_argument("--alert-total-pl-abs", type=float, default=None, help="합계 손익액 임계치(절대값, 리포트통화)")
    p.add_argument("--alerts-only", action="store_true", help="임계치 충족 시 알림만 전송(스냅샷 생략)")
    p.add_argument("--state-file", default=".naver_stock_state.json", help="임계치 중복 억제용 상태파일 경로")
    p.add_argument("--pid-file", default=DEFAULT_PID_FILE, help="단일 인스턴스 실행 보장 PID 파일 경로(빈 문자열이면 비활성)")
    p.add_argument("--no-pid-lock", action="store_true", help="PID 락 비활성화(중복 실행 허용)")

    args = p.parse_args()
    if args.insecure: VERIFY_SSL = False

    # 단일 인스턴스 실행(옵션)
    pid_file = "" if getattr(args, "no_pid_lock", False) else (getattr(args, "pid_file", "") or "")
    try:
        _acquire_pid_lock(pid_file)
    except Exception as e:
        log.error(str(e))
        sys.exit(1)

    # 외부 파일 로드(관대 파서)
    if args.stocks_file:
        try:
            STOCKS = _load_json(args.stocks_file); assert isinstance(STOCKS, dict); log.info("종목 로드: %d개", len(STOCKS))
        except Exception as e:
            log.error("종목 파일 로드 실패: %s", e)
    if args.portfolio_file:
        try:
            PORTFOLIO = _load_json(args.portfolio_file); assert isinstance(PORTFOLIO, dict); log.info("포트폴리오 로드: %d개", len(PORTFOLIO))
        except Exception as e:
            log.error("포트폴리오 파일 로드 실패: %s", e)
    if args.dividends_file:
        try:
            DIVIDENDS = _load_json(args.dividends_file); assert isinstance(DIVIDENDS, dict); log.info("분배금 로드: %d개 종목", len(DIVIDENDS))
        except Exception as e:
            log.error("분배금 파일 로드 실패: %s", e)

    # 환율: 파일 → 수동 CLI → 자동
    if args.fx_file:
        try: FX_RATES.update(_load_json(args.fx_file)); log.info("환율 로드: %s", FX_RATES)
        except Exception as e: log.error("환율 파일 로드 실패: %s", e)
    if args.fx:
        FX_RATES.update(_parse_fx_pairs(args.fx)); log.info("환율 적용(추가): %s", FX_RATES)

    # 자동 환율 설정
    _auto_fx_enabled = bool(args.auto_fx)
    _fx_refresh_minutes = int(args.fx_refresh_min or 720)
    _auto_fx_extra = {t.strip().upper() for t in (args.auto_fx_extra or "").split(",") if t.strip()}

    # 리포트 통화 안전값
    if args.report_ccy.upper() not in FX_RATES: FX_RATES&#91;args.report_ccy.upper()] = 1.0

    # 휴장일 합치기
    holidays: Set&#91;ddate] = set()
    if args.holiday: holidays |= _parse_holiday_dates(args.holiday)
    if args.holiday_file: holidays |= _load_holidays_from_file(args.holiday_file)

    # 수수료/세금 옵션(bps → rate)
    if args.commission_bps is None:
        venue_default_bps = 1.5 if args.venue == "krx" else 1.45
        commission_bps_effective = venue_default_bps
    else:
        commission_bps_effective = float(args.commission_bps)

    _commission_rate = float(commission_bps_effective) / 10000.0
    _sell_tax_rate_stock = float(args.sell_tax_bps_stock or 0.0) / 10000.0
    _sell_tax_rate_etf = float(args.sell_tax_bps_etf or 0.0) / 10000.0
    _include_buy_commission_in_cost = not bool(args.no_buy_commission_into_cost)

    log.info("&#91;FEE] venue=%s, commission=%.5f%%, tax(stock/etf)=%.5f%%/%.5f%%, buy_comm_in_cost=%s",
             args.venue, _commission_rate*100, _sell_tax_rate_stock*100, _sell_tax_rate_etf*100,
             _include_buy_commission_in_cost)

    # 시작 시 1회 환율 자동 갱신(옵션 켠 경우) — 최초 보장
    if _auto_fx_enabled:
        _last_fx_fetch = None
        _auto_fetch_and_update_fx(args.report_ccy.upper(), VERIFY_SSL)

    try:
        if args.market_hours:
            log.info("&#91;INFO] 장시간 모드 시작 (%s~%s, %d분 간격, 주말제외=%s, 휴장일=%d, SSL=%s, TZ=%s, REP=%s, AUTO_FX=%s/%d분)",
                     args.start, args.end, args.every_min, not args.no_weekdays_only, len(holidays),
                     VERIFY_SSL, args.tz, args.report_ccy.upper(), _auto_fx_enabled, _fx_refresh_minutes)
            run_market_hours(args.start, args.end, args.every_min, VERIFY_SSL,
                tz_name=args.tz, report_ccy=args.report_ccy.upper(),
                weekdays_only=not args.no_weekdays_only, holidays=holidays,
                mention=args.mention, title_prefix=args.title_prefix,
                compact=bool(args.compact),
                save_history=bool(args.save_history),
                history_dir=str(args.history_dir),
                history_prefix=str(args.history_prefix),
                alerts_only=args.alerts_only,
                alert_pl_pct=args.alert_pl_pct, alert_pl_abs=args.alert_pl_abs,
                alert_total_pl_pct=args.alert_total_pl_pct, alert_total_pl_abs=args.alert_total_pl_abs,
                state_file=args.state_file)
        elif args.daemon:
            log.info("&#91;INFO] 데몬 모드 시작 (간격: %d초, SSL=%s, TZ=%s, REP=%s, AUTO_FX=%s/%d분)",
                     args.interval, VERIFY_SSL, args.tz, args.report_ccy.upper(), _auto_fx_enabled, _fx_refresh_minutes)
            run_daemon(args.interval, VERIFY_SSL, tz_name=args.tz, report_ccy=args.report_ccy.upper(),
                mention=args.mention, title_prefix=args.title_prefix,
                compact=bool(args.compact),
                alerts_only=args.alerts_only,
                alert_pl_pct=args.alert_pl_pct, alert_pl_abs=args.alert_pl_abs,
                alert_total_pl_pct=args.alert_total_pl_pct, alert_total_pl_abs=args.alert_total_pl_abs,
                state_file=args.state_file)
        else:
            run_once(VERIFY_SSL, tz_name=args.tz, report_ccy=args.report_ccy.upper(),
                     mention=args.mention, title_prefix=args.title_prefix, compact=bool(args.compact),
                     save_history=bool(args.save_history), history_dir=str(args.history_dir), history_prefix=str(args.history_prefix))
    except KeyboardInterrupt:
        log.info("사용자 중단(Ctrl+C) 감지 → 안전 종료합니다.")
    except Exception as e:
        log.error("치명적 오류: %s", e); send_error_alert(str(e), VERIFY_SSL); sys.exit(1)

if __name__ == "__main__":
    main()

</code></pre>


<p>게시물 <a href="https://howinfo.kr/%ec%9d%b4-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%ed%95%98%eb%82%98%ec%97%90-%ed%88%ac%ec%9e%90-%ec%9e%90%eb%8f%99%ed%99%94%ec%9d%98-%eb%aa%a8%eb%93%a0-%ea%b2%83/">개인 투자용 주식 자동화 시스템 구조 설명</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ec%9d%b4-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%ed%95%98%eb%82%98%ec%97%90-%ed%88%ac%ec%9e%90-%ec%9e%90%eb%8f%99%ed%99%94%ec%9d%98-%eb%aa%a8%eb%93%a0-%ea%b2%83/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
