<?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/%ec%a3%bc%ec%8b%9d%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>Wed, 25 Feb 2026 13:12:24 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.4</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>Orange Pi 5로 “주식/ETF 트레일링 스탑 + 손절 + 리스크 레벨” 알림봇 만들기 (Synology Chat 웹훅, 10~30분 자동 주기)_버전 두번째 full 소스</title>
		<link>https://howinfo.kr/orange-pi-5%eb%a1%9c-%ec%a3%bc%ec%8b%9d-etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%86%90%ec%a0%88-%eb%a6%ac%ec%8a%a4%ed%81%ac-%eb%a0%88%eb%b2%a8-%ec%95%8c/</link>
					<comments>https://howinfo.kr/orange-pi-5%eb%a1%9c-%ec%a3%bc%ec%8b%9d-etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%86%90%ec%a0%88-%eb%a6%ac%ec%8a%a4%ed%81%ac-%eb%a0%88%eb%b2%a8-%ec%95%8c/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Wed, 25 Feb 2026 13:12:23 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[ETF]]></category>
		<category><![CDATA[yfinance]]></category>
		<category><![CDATA[리스크관리]]></category>
		<category><![CDATA[손절]]></category>
		<category><![CDATA[시놀로지chat]]></category>
		<category><![CDATA[시놀로지나스]]></category>
		<category><![CDATA[오렌지파이5]]></category>
		<category><![CDATA[웹훅]]></category>
		<category><![CDATA[주식자동화]]></category>
		<category><![CDATA[트레일링스탑]]></category>
		<category><![CDATA[파이썬 자동화]]></category>
		<category><![CDATA[포트폴리오관리]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=2104</guid>

					<description><![CDATA[<p>한 줄 요약 오렌지파이5에서 **보유 종목(주식/ETF)**을 파일로 관리하고, ※ 가격은 yfinance 기반이라 “지연 Close”로 동작합니다(실시간 아님).실시간으로 바꾸려면 추후 키움 API...</p>
<p>게시물 <a href="https://howinfo.kr/orange-pi-5%eb%a1%9c-%ec%a3%bc%ec%8b%9d-etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%86%90%ec%a0%88-%eb%a6%ac%ec%8a%a4%ed%81%ac-%eb%a0%88%eb%b2%a8-%ec%95%8c/">Orange Pi 5로 “주식/ETF 트레일링 스탑 + 손절 + 리스크 레벨” 알림봇 만들기 (Synology Chat 웹훅, 10~30분 자동 주기)_버전 두번째 full 소스</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 class="wp-block-heading">한 줄 요약</h2>



<p>오렌지파이5에서 **보유 종목(주식/ETF)**을 파일로 관리하고,</p>



<ul class="wp-block-list">
<li><strong>수익 최고점(peak) 대비 -X% 트레일링</strong></li>



<li><strong>매수가 대비 -Y% 손절</strong></li>



<li><strong>손절/트레일링 근접 경고</strong></li>



<li><strong>1시간 요약 / 15:35 요약 / 16:00 리포트</strong></li>



<li><strong>리스크 HIGH일 때만 30분→10분 자동 단축</strong></li>



<li><strong>다음날 자동 재감시(active=1 복구)</strong><br>를 <strong>시놀로지 Chat 웹훅으로 자동 알림</strong>해주는 파이썬 봇입니다.</li>
</ul>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>※ 가격은 yfinance 기반이라 “지연 Close”로 동작합니다(실시간 아님).<br>실시간으로 바꾸려면 추후 키움 API 등 연동이 필요합니다.</p>
</blockquote>



<figure class="wp-block-image size-full"><img fetchpriority="high" decoding="async" width="734" height="639" src="https://howinfo.kr/wp-content/uploads/2026/02/sychat1.png" alt="" class="wp-image-2105" srcset="https://howinfo.kr/wp-content/uploads/2026/02/sychat1.png 734w, https://howinfo.kr/wp-content/uploads/2026/02/sychat1-300x261.png 300w" sizes="(max-width: 734px) 100vw, 734px" /></figure>



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



<h1 class="wp-block-heading">1) 동작 개념(핵심 로직)</h1>



<h3 class="wp-block-heading">✅ 트레일링(수익 구간)</h3>



<ul class="wp-block-list">
<li>내 매수금액(평가금액 기준) 대비 최고점(peak)을 계속 갱신</li>



<li>기준선 = <code>peak × (1 - trailing%)</code></li>



<li>현재가가 기준선 이하로 내려오면 <strong>매도 알림</strong></li>
</ul>



<h3 class="wp-block-heading">✅ 가변 트레일링(수익이 커질수록 더 타이트)</h3>



<p>예: base trailing이 10%인 종목이라면</p>



<ul class="wp-block-list">
<li>수익 0~20%: 10%</li>



<li>수익 20~50%: 7% (10%×0.7)</li>



<li>수익 50% 이상: 5% (10%×0.5)</li>
</ul>



<h3 class="wp-block-heading">✅ 손절(매수가 기준)</h3>



<ul class="wp-block-list">
<li>손절선 = <code>매수금액 × (1 - stop_loss%)</code></li>



<li>현재가가 손절선 이하로 내려오면 <strong>손절 알림</strong></li>
</ul>



<h3 class="wp-block-heading">✅ 근접 경고</h3>



<ul class="wp-block-list">
<li>트레일링 기준선까지 <strong>2% 이내</strong>로 접근하면 “근접 경고”</li>



<li>손절선까지 <strong>2% 이내</strong>로 접근하면 “손절 근접 경고”</li>
</ul>



<h3 class="wp-block-heading">✅ 리스크 레벨(LOW / MEDIUM / HIGH)</h3>



<ul class="wp-block-list">
<li>Trigger(기준선 이하) / Near(근접) 종목 수</li>



<li>위험 노출 비중(Trigger+Near 종목 평가금액 합 / 전체 평가금액)<br>을 기준으로 <strong>LOW/MEDIUM/HIGH</strong> 표시<br>→ 장중 요약, 16:00 리포트에 같이 출력</li>
</ul>



<h3 class="wp-block-heading">✅ 실행 주기 자동 변경(장중만)</h3>



<ul class="wp-block-list">
<li>평소 장중: 30분</li>



<li>리스크 HIGH: 10분(더 촘촘히 감시)</li>



<li>장외: 120분(불필요 호출 절약)</li>
</ul>



<h3 class="wp-block-heading">✅ 다음날 자동 재활성화</h3>



<ul class="wp-block-list">
<li>매도/손절 알림 후 active=0으로 자동 비활성화(옵션)</li>



<li>다음날 09:01에 자동으로 active=1로 복구(옵션)</li>
</ul>



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



<h1 class="wp-block-heading">2) 폴더 구성(그대로 만들기)</h1>



<p>아래 3개 파일만 있으면 됩니다.</p>



<pre class="wp-block-preformatted">jusik_top10/<br> ├─ main.py<br> ├─ config.json<br> ├─ positions.csv<br> └─ state.json   (없으면 자동 생성됨, 처음엔 {} 권장)</pre>



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



<h1 class="wp-block-heading">3) 설치(오렌지파이5)</h1>



<pre class="wp-block-preformatted">sudo apt update<br>sudo apt install -y python3-pip<br>python3 -m pip install --user requests yfinance</pre>



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



<h1 class="wp-block-heading">4) config.json (복붙용)</h1>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><code>synology_chat_webhook_url</code>만 본인 값으로 바꿔주세요.</p>
</blockquote>



<pre class="wp-block-preformatted">{<br>  "synology_chat_webhook_url": "여기에_시놀로지챗_웹훅_URL_붙여넣기",  "default_trailing_pct": 0.10,<br>  "default_stop_loss_pct": 0.08,  "market_open": "09:00",<br>  "market_close": "15:30",  "startup_notify_enabled": true,  "near_alert_enabled": true,<br>  "near_alert_distance_pct": 2.0,<br>  "near_alert_cooldown_minutes": 60,  "stoploss_near_enabled": true,<br>  "stoploss_near_distance_pct": 2.0,<br>  "stoploss_near_cooldown_minutes": 180,  "auto_disable_on_alert": true,  "hourly_summary_enabled": true,<br>  "hourly_summary_interval_minutes": 60,  "daily_summary_time": "15:35",  "after_close_summary_enabled": true,<br>  "after_close_summary_time": "16:00",<br>  "after_close_include_inactive": false,  "adaptive_trailing_enabled": true,<br>  "adaptive_trailing_factors": [<br>    { "min_profit_pct": 0,  "factor": 1.0 },<br>    { "min_profit_pct": 20, "factor": 0.7 },<br>    { "min_profit_pct": 50, "factor": 0.5 }<br>  ],  "risk_high_near_count": 3,<br>  "risk_high_near_exposure_pct": 30,  "adaptive_interval_enabled": true,<br>  "interval_minutes_market": 30,<br>  "interval_minutes_high_risk": 10,<br>  "interval_minutes_off_market": 120,  "daily_reactivate_enabled": true,<br>  "daily_reactivate_time": "09:01"<br>}</pre>



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



<h1 class="wp-block-heading">5) positions.csv (복붙용)</h1>



<ul class="wp-block-list">
<li><code>market</code>: KS(코스피), KQ(코스닥)</li>



<li><code>trailing_pct</code>: 0.10 = 10%</li>



<li><code>stop_loss_pct</code>: 0.08 = -8%</li>



<li><code>active</code>: 1(감시), 0(감시중지)</li>
</ul>



<pre class="wp-block-preformatted">code,name,market,qty,buy_price,trailing_pct,stop_loss_pct,active<br>005930,삼성전자,KS,10,70000,0.10,0.08,1<br>069500,KODEX 200,KS,20,35000,0.08,0.07,1</pre>



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



<h1 class="wp-block-heading">6) state.json 초기화(권장)</h1>



<p>처음 적용할 때 state가 꼬였으면 초기화가 안전합니다.</p>



<pre class="wp-block-preformatted">echo "{}" &gt; state.json</pre>



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



<h1 class="wp-block-heading">7) main.py (전체 소스 — 그대로 복붙)</h1>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>아래 코드를 <code>main.py</code>로 저장하세요.</p>
</blockquote>



<pre class="wp-block-preformatted"># (아래 전체 코드 그대로 복붙)<br>"""<br>Orange Pi 5 (Ubuntu/ARM) 주식/ETF 트레일링 스탑 알림 시스템 (yfinance 기반, 지연 Close OK)[핵심 기능]<br>- positions.csv 관리: 종목/수량/매수가/trailing_pct/stop_loss_pct/active<br>- state.json 유지: peak_value, last_alerted_peak, 근접 경고 시각, 요약 발송 기록 등<br>- Synology Chat Incoming Webhook으로 알림 전송[장중(09:00~15:30) 기능]<br>1) 손절(매수가 기준) 알림: entry*(1-stop_loss_pct) 이하<br>2) 손절 근접 경고: 손절선까지 +X% 남으면(0% 이하면 손절)<br>3) 트레일링(수익구간) 알림: peak*(1-eff_trailing) 이하<br>4) 트레일링 근접 경고: 기준선까지 +X% 남으면<br>5) 1시간 요약(장중): 총 수익률 + 종목별 요약 + 리스크 레벨[장마감 이후]<br>- 15:35 일일 요약(TOP3/WORST3)<br>- 16:00 리포트(섹션 분리: TRIGGER/NEAR/SAFE + 손절 정보 + 리스크)[추가 보강]<br>- 가변 트레일링(Adaptive Trailing): base_trailing_pct × factor(수익구간별)<br>- 리스크 HIGH일 때 장중 주기 자동 단축(예: 30→10분)<br>- 매도/손절 알림 후 auto_disable_on_alert=true면 active=0 자동 변경<br>- 다음날 지정 시간에 active=0 → active=1 자동 재활성화(옵션)<br>- state.json 깨짐 자동복구 + 원자저장<br>- positions.csv 원자저장<br>- 시작 1회 안내 메시지[주의]<br>- yfinance는 지연 Close 기반(실시간 아님)<br>"""import os<br>import json<br>import time<br>import csv<br>import tempfile<br>from dataclasses import dataclass<br>from datetime import datetime, time as dtimeimport requests<br>import yfinance as yfBASE = os.path.dirname(os.path.abspath(__file__))<br>CONFIG_PATH = os.path.join(BASE, "config.json")<br>POSITIONS_PATH = os.path.join(BASE, "positions.csv")<br>STATE_PATH = os.path.join(BASE, "state.json")def log(step: str, msg: str = "", level: str = "INFO"):<br>    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")<br>    if msg:<br>        print(f"[{now}] [{level}] [{step}] {msg}")<br>    else:<br>        print(f"[{now}] [{level}] [{step}]")def fmt_money(x) -&gt; str:<br>    return f"{x:,.0f}"def parse_hhmm(s: str) -&gt; dtime:<br>    hh, mm = s.split(":")<br>    return dtime(hour=int(hh), minute=int(mm))def is_market_open(now: datetime, open_t: dtime, close_t: dtime) -&gt; bool:<br>    t = now.time()<br>    return open_t &lt;= t &lt;= close_tdef load_json_safe(path, default):<br>    if not os.path.exists(path):<br>        return default<br>    try:<br>        with open(path, "r", encoding="utf-8") as f:<br>            return json.load(f)<br>    except json.JSONDecodeError as e:<br>        ts = datetime.now().strftime("%Y%m%d_%H%M%S")<br>        bak = f"{path}.broken_{ts}"<br>        try:<br>            os.replace(path, bak)<br>        except:<br>            try:<br>                import shutil<br>                shutil.copy2(path, bak)<br>            except:<br>                pass<br>        print(f"[WARN] JSON이 깨져 자동 복구했습니다. 백업: {bak} / 오류: {e}")<br>        return defaultdef save_json_atomic(path, data):<br>    tmp_dir = os.path.dirname(path) or "."<br>    fd, tmp_path = tempfile.mkstemp(prefix="state_", suffix=".json", dir=tmp_dir)<br>    os.close(fd)<br>    try:<br>        with open(tmp_path, "w", encoding="utf-8") as f:<br>            json.dump(data, f, ensure_ascii=False, indent=2)<br>        os.replace(tmp_path, path)<br>    finally:<br>        if os.path.exists(tmp_path):<br>            try:<br>                os.remove(tmp_path)<br>            except:<br>                passdef parse_float_optional(x):<br>    if x is None:<br>        return None<br>    s = str(x).strip()<br>    if s == "":<br>        return None<br>    return float(s)def parse_int_optional(x, default=1):<br>    if x is None:<br>        return default<br>    s = str(x).strip()<br>    if s == "":<br>        return default<br>    try:<br>        return int(float(s))<br>    except:<br>        return default@dataclass<br>class Position:<br>    code: str<br>    name: str<br>    market: str<br>    qty: float<br>    buy_price: float<br>    trailing_pct: float<br>    stop_loss_pct: float<br>    active: int    @property<br>    def ticker(self) -&gt; str:<br>        return f"{self.code}.{self.market.upper()}"def load_positions_csv(path, default_trailing_pct: float, default_stop_loss_pct: float) -&gt; list[Position]:<br>    if not os.path.exists(path):<br>        raise RuntimeError(f"positions.csv가 없습니다: {path}")    positions = []<br>    with open(path, "r", encoding="utf-8-sig") as f:<br>        reader = csv.DictReader(f)<br>        required = {"code", "qty", "buy_price"}<br>        if not required.issubset(set(reader.fieldnames or [])):<br>            raise RuntimeError(f"positions.csv 컬럼이 필요합니다: {sorted(required)}")        for row in reader:<br>            code = row["code"].strip()<br>            name = (row.get("name") or code).strip()<br>            market = (row.get("market") or "KS").strip().upper()<br>            if market not in ("KS", "KQ"):<br>                raise RuntimeError(f"{code} market 값 오류: {market} (KS/KQ만 허용)")            qty = float(row["qty"])<br>            buy_price = float(row["buy_price"])            trailing_pct = parse_float_optional(row.get("trailing_pct"))<br>            if trailing_pct is None:<br>                trailing_pct = default_trailing_pct<br>            if not (0 &lt; trailing_pct &lt; 1):<br>                raise RuntimeError(f"{code} trailing_pct 비정상: {trailing_pct}")            stop_loss_pct = parse_float_optional(row.get("stop_loss_pct"))<br>            if stop_loss_pct is None:<br>                stop_loss_pct = default_stop_loss_pct<br>            if not (0 &lt;= stop_loss_pct &lt; 1):<br>                raise RuntimeError(f"{code} stop_loss_pct 비정상: {stop_loss_pct}")            active = parse_int_optional(row.get("active"), default=1)<br>            active = 1 if active != 0 else 0            positions.append(Position(code, name, market, qty, buy_price, trailing_pct, stop_loss_pct, active))<br>    return positionsdef save_positions_csv_atomic(path, positions: list[Position]):<br>    fieldnames = ["code", "name", "market", "qty", "buy_price", "trailing_pct", "stop_loss_pct", "active"]<br>    tmp_dir = os.path.dirname(path) or "."<br>    fd, tmp_path = tempfile.mkstemp(prefix="positions_", suffix=".csv", dir=tmp_dir)<br>    os.close(fd)<br>    try:<br>        with open(tmp_path, "w", encoding="utf-8-sig", newline="") as f:<br>            w = csv.DictWriter(f, fieldnames=fieldnames)<br>            w.writeheader()<br>            for p in positions:<br>                w.writerow({<br>                    "code": p.code, "name": p.name, "market": p.market,<br>                    "qty": p.qty, "buy_price": p.buy_price,<br>                    "trailing_pct": p.trailing_pct, "stop_loss_pct": p.stop_loss_pct,<br>                    "active": p.active<br>                })<br>        os.replace(tmp_path, path)<br>    finally:<br>        if os.path.exists(tmp_path):<br>            try: os.remove(tmp_path)<br>            except: passdef send_synology_chat(webhook_url: str, text: str):<br>    payload_json = json.dumps({"text": text}, ensure_ascii=False)<br>    r = requests.post(webhook_url, data={"payload": payload_json}, timeout=10)<br>    r.raise_for_status()def get_last_price_yf(ticker: str) -&gt; float:<br>    hist = yf.Ticker(ticker).history(period="7d")<br>    if hist is None or hist.empty:<br>        raise RuntimeError(f"가격 데이터 비어있음: {ticker}")<br>    return float(hist["Close"].iloc[-1])def get_adaptive_factor(cfg: dict, peak_profit_pct: float) -&gt; float:<br>    if not bool(cfg.get("adaptive_trailing_enabled", True)):<br>        return 1.0<br>    tiers = cfg.get("adaptive_trailing_factors", [])<br>    if not isinstance(tiers, list) or len(tiers) == 0:<br>        return 1.0    valid = []<br>    for t in tiers:<br>        try:<br>            m = float(t.get("min_profit_pct", 0))<br>            f = float(t.get("factor", 1.0))<br>            if f &gt; 0:<br>                valid.append((m, f))<br>        except:<br>            pass    if not valid:<br>        return 1.0    valid.sort(key=lambda x: x[0])<br>    chosen = valid[0][1]<br>    for m, f in valid:<br>        if peak_profit_pct &gt;= m:<br>            chosen = f<br>        else:<br>            break<br>    return chosendef effective_trailing_pct(cfg: dict, base_trailing_pct: float, entry_value: float, peak_value: float) -&gt; float:<br>    if entry_value &lt;= 0:<br>        return base_trailing_pct<br>    peak_profit_pct = (peak_value - entry_value) / entry_value * 100.0<br>    factor = get_adaptive_factor(cfg, peak_profit_pct)<br>    eff = base_trailing_pct * factor<br>    eff = max(eff, 0.01)<br>    eff = min(eff, base_trailing_pct)<br>    return effdef cleanup_state(state: dict, active_codes: set[str]):<br>    for k in list(state.keys()):<br>        if k in ("_summary", "_hourly_summary", "_startup", "_after_close_summary", "_reactivate"):<br>            continue<br>        if k not in active_codes:<br>            del state[k]def should_send_daily_summary(state: dict, now: datetime, summary_time: dtime) -&gt; bool:<br>    if now.time() &lt; summary_time:<br>        return False<br>    today = now.strftime("%Y-%m-%d")<br>    s = state.setdefault("_summary", {})<br>    return s.get("last_date") != todaydef should_send_hourly_summary_market_only(state: dict, now: datetime, interval_minutes: int, market_open: bool) -&gt; bool:<br>    if not market_open:<br>        return False<br>    hourly = state.setdefault("_hourly_summary", {})<br>    last_str = hourly.get("last_sent")<br>    if not last_str:<br>        return True<br>    last_dt = datetime.strptime(last_str, "%Y-%m-%d %H:%M:%S")<br>    diff_min = (now - last_dt).total_seconds() / 60.0<br>    return diff_min &gt;= interval_minutesdef should_send_after_close_summary(state: dict, now: datetime, summary_time: dtime) -&gt; bool:<br>    if now.time() &lt; summary_time:<br>        return False<br>    today = now.strftime("%Y-%m-%d")<br>    s = state.setdefault("_after_close_summary", {})<br>    return s.get("last_date") != todaydef should_send_startup_notify(state: dict) -&gt; bool:<br>    st = state.setdefault("_startup", {})<br>    return not bool(st.get("sent", False))def mark_startup_notify_sent(state: dict):<br>    st = state.setdefault("_startup", {})<br>    st["sent"] = True<br>    st["sent_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")def should_reactivate_today(state: dict, now: datetime, hhmm: str) -&gt; bool:<br>    t = parse_hhmm(hhmm)<br>    if now.time() &lt; t:<br>        return False<br>    today = now.strftime("%Y-%m-%d")<br>    s = state.setdefault("_reactivate", {})<br>    return s.get("last_date") != todaydef reactivate_all_positions(positions_all: list[Position], state: dict) -&gt; bool:<br>    changed = False<br>    for p in positions_all:<br>        if p.active == 0:<br>            p.active = 1<br>            changed = True<br>    if changed:<br>        state.setdefault("_reactivate", {})["last_date"] = datetime.now().strftime("%Y-%m-%d")<br>    return changeddef calc_risk_level(detail_list: list[dict], total_current_active: float, cfg: dict, near_dist: float) -&gt; dict:<br>    trigger = [d for d in detail_list if d.get("active") == 1 and d.get("to_stop", 999) &lt;= 0]<br>    near = [d for d in detail_list if d.get("active") == 1 and 0 &lt; d.get("to_stop", 999) &lt;= near_dist]    trigger_cnt = len(trigger)<br>    near_cnt = len(near)    risk_exposure = sum(float(d.get("cur", 0.0)) for d in (trigger + near))<br>    exposure_pct = (risk_exposure / total_current_active * 100.0) if total_current_active &gt; 0 else 0.0    high_near_cnt = int(cfg.get("risk_high_near_count", 3))<br>    high_exposure_pct = float(cfg.get("risk_high_near_exposure_pct", 30))    if trigger_cnt &gt; 0 or near_cnt &gt;= high_near_cnt or exposure_pct &gt;= high_exposure_pct:<br>        level = "HIGH"<br>    elif near_cnt &gt; 0:<br>        level = "MEDIUM"<br>    else:<br>        level = "LOW"    return {<br>        "level": level,<br>        "trigger_cnt": trigger_cnt,<br>        "near_cnt": near_cnt,<br>        "risk_exposure": risk_exposure,<br>        "exposure_pct": exposure_pct<br>    }def top_worst_lines(rows, n=3):<br>    rows_desc = sorted(rows, key=lambda r: r["rate_now"], reverse=True)[:n]<br>    rows_asc = sorted(rows, key=lambda r: r["rate_now"], reverse=False)[:n]    def line(r):<br>        sign = "+" if r["profit_now"] &gt;= 0 else ""<br>        return f"- {r['name']}({r['code']}): {r['rate_now']:+.2f}% ({sign}{fmt_money(r['profit_now'])}원)"    return [line(r) for r in rows_desc], [line(r) for r in rows_asc]def run_once(cfg: dict, positions_all: list[Position], state: dict):<br>    now = datetime.now()<br>    now_str = now.strftime("%Y-%m-%d %H:%M:%S")    webhook_url = cfg["synology_chat_webhook_url"]    market_open_t = parse_hhmm(cfg.get("market_open", "09:00"))<br>    market_close_t = parse_hhmm(cfg.get("market_close", "15:30"))<br>    daily_summary_t = parse_hhmm(cfg.get("daily_summary_time", "15:35"))    hourly_enabled = bool(cfg.get("hourly_summary_enabled", True))<br>    hourly_interval = int(cfg.get("hourly_summary_interval_minutes", 60))    auto_disable = bool(cfg.get("auto_disable_on_alert", True))    near_enabled = bool(cfg.get("near_alert_enabled", True))<br>    near_dist = float(cfg.get("near_alert_distance_pct", 2.0))<br>    near_cooldown = int(cfg.get("near_alert_cooldown_minutes", 60))    sl_near_enabled = bool(cfg.get("stoploss_near_enabled", True))<br>    sl_near_dist = float(cfg.get("stoploss_near_distance_pct", 2.0))<br>    sl_near_cooldown = int(cfg.get("stoploss_near_cooldown_minutes", 180))    after_close_enabled = bool(cfg.get("after_close_summary_enabled", True))<br>    after_close_time = parse_hhmm(cfg.get("after_close_summary_time", "16:00"))<br>    include_inactive = bool(cfg.get("after_close_include_inactive", False))    positions_active = [p for p in positions_all if p.active == 1]<br>    active_codes = {p.code for p in positions_active}<br>    cleanup_state(state, active_codes)    market_open = is_market_open(now, market_open_t, market_close_t)<br>    log("MARKET", f"장중={market_open}")    price_targets = positions_all if include_inactive else positions_active<br>    price_cache: dict[str, float] = {}    log("PRICE", f"조회 대상={len(price_targets)}")<br>    for p in price_targets:<br>        if p.code in price_cache:<br>            continue<br>        try:<br>            price_cache[p.code] = get_last_price_yf(p.ticker)<br>        except Exception as e:<br>            log("PRICE", f"FAIL {p.name}({p.code}) {e}", level="WARN")    positions_ok = [p for p in positions_active if p.code in price_cache]<br>    if not positions_ok:<br>        return False, "LOW", market_open    total_entry = total_current = total_peak = 0.0<br>    rows = []    for p in positions_ok:<br>        entry_value = p.qty * p.buy_price<br>        current_value = p.qty * price_cache[p.code]<br>        s = state.setdefault(p.code, {})        peak_value = float(s.get("peak_value", entry_value))<br>        if current_value &gt; peak_value:<br>            peak_value = current_value<br>        s["peak_value"] = peak_value        profit_now = current_value - entry_value<br>        rate_now = (profit_now / entry_value * 100.0) if entry_value else 0.0        total_entry += entry_value<br>        total_current += current_value<br>        total_peak += peak_value        rows.append({"code": p.code, "name": p.name, "profit_now": profit_now, "rate_now": rate_now})    total_profit_now = total_current - total_entry<br>    total_rate_now = (total_profit_now / total_entry * 100.0) if total_entry else 0.0    changed_positions = False<br>    if market_open:<br>        pos_map = {p.code: p for p in positions_all}        for p in positions_ok:<br>            entry_value = p.qty * p.buy_price<br>            current_value = p.qty * price_cache[p.code]<br>            s = state.setdefault(p.code, {})<br>            peak_value = float(s.get("peak_value", entry_value))            # 손절 근접 + 손절 트리거<br>            if p.stop_loss_pct &gt; 0:<br>                stop_loss_value = entry_value * (1 - p.stop_loss_pct)<br>                to_stoploss = (current_value - stop_loss_value) / stop_loss_value * 100.0 if stop_loss_value &gt; 0 else None                if sl_near_enabled and to_stoploss is not None and (0 &lt; to_stoploss &lt;= sl_near_dist):<br>                    ns = state.setdefault(p.code, {})<br>                    last_sl_near = ns.get("last_stoploss_near_alert_at")<br>                    can_send = True<br>                    if last_sl_near:<br>                        last_dt = datetime.strptime(last_sl_near, "%Y-%m-%d %H:%M:%S")<br>                        can_send = (now - last_dt).total_seconds() &gt;= sl_near_cooldown * 60<br>                    if can_send:<br>                        msg = (<br>                            f"[손절 근접 경고/매수가 -{int(p.stop_loss_pct*100)}%]\n"<br>                            f"- {p.name}({p.code})\n"<br>                            f"- 손절선까지 거리: {to_stoploss:+.2f}% (0% 이하이면 손절)\n"<br>                            f"- 최근가(지연 Close): {fmt_money(price_cache[p.code])}원\n"<br>                            f"- 손절선: {fmt_money(stop_loss_value)}원\n"<br>                            f"- 평가금액: {fmt_money(current_value)}원\n"<br>                            f"- 시각: {now_str}"<br>                        )<br>                        send_synology_chat(webhook_url, msg)<br>                        ns["last_stoploss_near_alert_at"] = now_str                stop_loss_triggered = current_value &lt;= stop_loss_value<br>                last_sl = s.get("last_stop_loss_alert_at")<br>                already_today = bool(last_sl and last_sl[:10] == now_str[:10])<br>                if stop_loss_triggered and not already_today:<br>                    msg = (<br>                        f"[손절 알림/매수가 -{int(p.stop_loss_pct*100)}%]\n"<br>                        f"- {p.name}({p.code})\n"<br>                        f"- 최근가(지연 Close): {fmt_money(price_cache[p.code])}원\n"<br>                        f"- 평가금액: {fmt_money(current_value)}원\n"<br>                        f"- 손절선: {fmt_money(stop_loss_value)}원\n"<br>                        f"- 매수금액: {fmt_money(entry_value)}원\n"<br>                        f"- 시각: {now_str}"<br>                    )<br>                    send_synology_chat(webhook_url, msg)<br>                    s["last_stop_loss_alert_at"] = now_str<br>                    if auto_disable and pos_map[p.code].active != 0:<br>                        pos_map[p.code].active = 0<br>                        changed_positions = True            # 트레일링 근접 + 트리거<br>            last_alerted_peak = float(s.get("last_alerted_peak", 0))<br>            if peak_value &gt; entry_value:<br>                eff_trailing = effective_trailing_pct(cfg, p.trailing_pct, entry_value, peak_value)<br>                stop_value = peak_value * (1 - eff_trailing)<br>                to_stop = (current_value - stop_value) / stop_value * 100.0<br>                drop_from_peak = (peak_value - current_value) / peak_value * 100.0<br>                triggered = current_value &lt;= stop_value                if near_enabled and (0 &lt; to_stop &lt;= near_dist):<br>                    ns = state.setdefault(p.code, {})<br>                    last_near = ns.get("last_near_alert_at")<br>                    can_send = True<br>                    if last_near:<br>                        last_dt = datetime.strptime(last_near, "%Y-%m-%d %H:%M:%S")<br>                        can_send = (now - last_dt).total_seconds() &gt;= near_cooldown * 60<br>                    if can_send:<br>                        msg = (<br>                            f"[근접 경고/트레일링 {int(eff_trailing*100)}%]\n"<br>                            f"- {p.name}({p.code})\n"<br>                            f"- 기준선까지 거리: {to_stop:+.2f}% (0% 이하이면 트리거)\n"<br>                            f"- 피크 대비 하락률: {drop_from_peak:.2f}%\n"<br>                            f"- 최근가(지연 Close): {fmt_money(price_cache[p.code])}원\n"<br>                            f"- 시각: {now_str}"<br>                        )<br>                        send_synology_chat(webhook_url, msg)<br>                        ns["last_near_alert_at"] = now_str                if triggered and last_alerted_peak &lt; peak_value:<br>                    msg = (<br>                        f"[매도 알림/트레일링 {int(eff_trailing*100)}%]\n"<br>                        f"- {p.name}({p.code})\n"<br>                        f"- 기준선까지 거리: {to_stop:+.2f}% (트리거)\n"<br>                        f"- 피크 대비 하락률: {drop_from_peak:.2f}%\n"<br>                        f"- 최근가(지연 Close): {fmt_money(price_cache[p.code])}원\n"<br>                        f"- 시각: {now_str}"<br>                    )<br>                    send_synology_chat(webhook_url, msg)<br>                    s["last_alerted_peak"] = peak_value<br>                    if auto_disable and pos_map[p.code].active != 0:<br>                        pos_map[p.code].active = 0<br>                        changed_positions = True    # 리스크/리포트용 상세 리스트<br>    positions_for_report = positions_all if include_inactive else positions_active<br>    detail_list = []<br>    for p in positions_for_report:<br>        if p.code not in price_cache:<br>            continue<br>        entry_value = p.qty * p.buy_price<br>        current_value = p.qty * price_cache[p.code]<br>        s = state.get(p.code, {})<br>        peak_value = float(s.get("peak_value", entry_value))<br>        eff_trailing = effective_trailing_pct(cfg, p.trailing_pct, entry_value, peak_value) if peak_value &gt; entry_value else p.trailing_pct<br>        stop_value = peak_value * (1 - eff_trailing) if peak_value else 0.0<br>        to_stop = (current_value - stop_value) / stop_value * 100.0 if stop_value else 0.0        stop_loss_value = entry_value * (1 - p.stop_loss_pct) if p.stop_loss_pct &gt; 0 else 0.0<br>        to_stoploss = (current_value - stop_loss_value) / stop_loss_value * 100.0 if stop_loss_value &gt; 0 else None        profit_now = current_value - entry_value<br>        rate_now = (profit_now / entry_value * 100.0) if entry_value else 0.0        detail_list.append({<br>            "name": p.name, "code": p.code, "active": p.active,<br>            "cur": current_value, "entry": entry_value,<br>            "profit": profit_now, "rate": rate_now,<br>            "peak": peak_value, "eff_trailing": eff_trailing,<br>            "stop": stop_value, "to_stop": to_stop,<br>            "stop_loss_pct": p.stop_loss_pct, "stop_loss_value": stop_loss_value, "to_stoploss": to_stoploss<br>        })    risk = calc_risk_level(detail_list, total_current, cfg, near_dist)    # 장중 1시간 요약<br>    if bool(cfg.get("hourly_summary_enabled", True)):<br>        if should_send_hourly_summary_market_only(state, now, int(cfg.get("hourly_summary_interval_minutes", 60)), market_open):<br>            lines = []<br>            for r in rows:<br>                sign = "+" if r["profit_now"] &gt;= 0 else ""<br>                lines.append(f"- {r['name']}({r['code']}): {r['rate_now']:+.2f}% ({sign}{fmt_money(r['profit_now'])}원)")            risk_box = (<br>                f"[리스크 레벨: {risk['level']}]\n"<br>                f"- Trigger: {risk['trigger_cnt']} / Near: {risk['near_cnt']}\n"<br>                f"- 위험노출: {fmt_money(risk['risk_exposure'])}원 ({risk['exposure_pct']:.1f}%)\n"<br>            )            msg = (<br>                f"[1시간 포트폴리오 요약]\n"<br>                f"- 총 손익: {fmt_money(total_profit_now)}원\n"<br>                f"- 총 수익률: {total_rate_now:+.2f}%\n\n"<br>                + risk_box + "\n"<br>                + "\n".join(lines) +<br>                f"\n\n- 시각: {now_str}"<br>            )<br>            send_synology_chat(webhook_url, msg)<br>            state.setdefault("_hourly_summary", {})["last_sent"] = now_str    # 15:35 일일 요약<br>    if should_send_daily_summary(state, now, daily_summary_t):<br>        total_profit_peak = total_peak - total_entry<br>        total_rate_peak = (total_profit_peak / total_entry * 100.0) if total_entry else 0.0<br>        top3, worst3 = top_worst_lines(rows, n=3)        msg = (<br>            f"[포트폴리오 일일 요약]\n"<br>            f"- 총 매수금액: {fmt_money(total_entry)}원\n"<br>            f"- 총 평가금액(현재): {fmt_money(total_current)}원\n"<br>            f"- 총 손익(현재): {fmt_money(total_profit_now)}원 ({total_rate_now:+.2f}%)\n"<br>            f"- 총 최고평가(피크): {fmt_money(total_peak)}원\n"<br>            f"- 총 손익(피크): {fmt_money(total_profit_peak)}원 ({total_rate_peak:+.2f}%)\n"<br>            f"\n[손익률 TOP3]\n" + "\n".join(top3) +<br>            f"\n\n[손익률 WORST3]\n" + "\n".join(worst3) +<br>            f"\n\n- 시각: {now_str}"<br>        )<br>        send_synology_chat(webhook_url, msg)<br>        state.setdefault("_summary", {})["last_date"] = now.strftime("%Y-%m-%d")    # 16:00 리포트(섹션 분리)<br>    if bool(cfg.get("after_close_summary_enabled", True)) and should_send_after_close_summary(state, now, after_close_time):<br>        trigger = [d for d in detail_list if d["active"] == 1 and d["to_stop"] &lt;= 0]<br>        near = [d for d in detail_list if d["active"] == 1 and 0 &lt; d["to_stop"] &lt;= near_dist]<br>        safe = [d for d in detail_list if d["active"] == 1 and d["to_stop"] &gt; near_dist]        def line(d):<br>            a = "A1" if d["active"] == 1 else "A0"<br>            sl_part = ""<br>            if d["stop_loss_pct"] &gt; 0 and d["to_stoploss"] is not None:<br>                sl_part = f" / to_SL={d['to_stoploss']:+.2f}%(-{int(d['stop_loss_pct']*100)}%)"<br>            sign = "+" if d["profit"] &gt;= 0 else ""<br>            return (<br>                f"- {d['name']}({d['code']})[{a}] to_stop={d['to_stop']:+.2f}%{sl_part}\n"<br>                f"  cur={fmt_money(d['cur'])} stop={fmt_money(d['stop'])} eff_trailing={int(d['eff_trailing']*100)}% "<br>                f"pnl={sign}{fmt_money(d['profit'])}원 ({d['rate']:+.2f}%)"<br>            )        risk_box = (<br>            f"[리스크 레벨: {risk['level']}]\n"<br>            f"- Trigger: {risk['trigger_cnt']} / Near: {risk['near_cnt']}\n"<br>            f"- 위험노출: {fmt_money(risk['risk_exposure'])}원 ({risk['exposure_pct']:.1f}%)\n"<br>        )        msg = (<br>            f"[16:00 리포트/기준선+손절+리스크]\n"<br>            f"- 총 손익(active=1): {fmt_money(total_profit_now)}원\n"<br>            f"- 총 수익률(active=1): {total_rate_now:+.2f}%\n"<br>            f"- 설명: to_stop=+X%는 트레일 기준선까지 남은 비율(0% 이하 트리거)\n"<br>            f"- 설명: to_SL=+X%는 손절선까지 남은 비율(0% 이하 손절)\n\n"<br>            + risk_box + "\n"<br>            + "🚨 TRIGGER\n" + ("\n".join(line(d) for d in trigger) if trigger else "- 없음") + "\n\n"<br>            + f"⚠️ NEAR (&lt;= {near_dist:.1f}%)\n" + ("\n".join(line(d) for d in near) if near else "- 없음") + "\n\n"<br>            + "✅ SAFE\n" + ("\n".join(line(d) for d in safe) if safe else "- 없음") +<br>            f"\n\n- 시각: {now_str}"<br>        )<br>        send_synology_chat(webhook_url, msg)<br>        state.setdefault("_after_close_summary", {})["last_date"] = now.strftime("%Y-%m-%d")    if changed_positions:<br>        save_positions_csv_atomic(POSITIONS_PATH, positions_all)    return changed_positions, risk["level"], market_opendef main():<br>    cfg = load_json_safe(CONFIG_PATH, {})<br>    if not cfg:<br>        raise RuntimeError("config.json이 없습니다.")    default_trailing_pct = float(cfg.get("default_trailing_pct", 0.10))<br>    default_stop_loss_pct = float(cfg.get("default_stop_loss_pct", 0.08))    state = load_json_safe(STATE_PATH, {})    if bool(cfg.get("startup_notify_enabled", True)) and should_send_startup_notify(state):<br>        try:<br>            positions_all = load_positions_csv(POSITIONS_PATH, default_trailing_pct, default_stop_loss_pct)<br>            active_positions = [p for p in positions_all if p.active == 1]<br>            lines = [<br>                f"- {p.name}({p.code}.{p.market}) qty={p.qty:g} buy={fmt_money(p.buy_price)} "<br>                f"trail={int(p.trailing_pct*100)}% sl={int(p.stop_loss_pct*100)}%"<br>                for p in active_positions<br>            ]<br>            msg = "[주식 알림봇 시작]\n" + "\n".join(lines) + f"\n- 시각: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"<br>            send_synology_chat(cfg["synology_chat_webhook_url"], msg)<br>            mark_startup_notify_sent(state)<br>            save_json_atomic(STATE_PATH, state)<br>        except Exception as e:<br>            log("STARTUP", f"시작 안내 실패: {e}", level="WARN")    while True:<br>        now = datetime.now()<br>        try:<br>            positions_all = load_positions_csv(POSITIONS_PATH, default_trailing_pct, default_stop_loss_pct)            if bool(cfg.get("daily_reactivate_enabled", True)):<br>                if should_reactivate_today(state, now, cfg.get("daily_reactivate_time", "09:01")):<br>                    if reactivate_all_positions(positions_all, state):<br>                        save_positions_csv_atomic(POSITIONS_PATH, positions_all)<br>                        save_json_atomic(STATE_PATH, state)            changed, risk_level, market_open = run_once(cfg, positions_all, state)<br>            save_json_atomic(STATE_PATH, state)            sleep_min = int(cfg.get("interval_minutes_market", 30))<br>            if bool(cfg.get("adaptive_interval_enabled", True)):<br>                mkt_min = int(cfg.get("interval_minutes_market", sleep_min))<br>                high_min = int(cfg.get("interval_minutes_high_risk", 10))<br>                off_min = int(cfg.get("interval_minutes_off_market", 120))<br>                sleep_min = (high_min if risk_level == "HIGH" else mkt_min) if market_open else off_min            log("SLEEP", f"{sleep_min}분 후 재실행 (risk={risk_level}, market_open={market_open})")<br>            time.sleep(sleep_min * 60)        except Exception as e:<br>            log("ERROR", f"루프 오류: {e}", level="ERROR")<br>            time.sleep(120)if __name__ == "__main__":<br>    main()</pre>



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



<h1 class="wp-block-heading">8) 실행(백그라운드)</h1>



<pre class="wp-block-preformatted">cd ~/python/jusik_top10<br>nohup python3.9 main.py &gt; jusik.log 2&gt;&amp;1 &amp;<br>tail -f jusik.log</pre>



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



<h1 class="wp-block-heading">9) 운영 팁</h1>



<ul class="wp-block-list">
<li><code>positions.csv</code>에서 <code>active=0</code>으로 수동 비활성화 가능</li>



<li>손절/트레일링 트리거 후 <code>auto_disable_on_alert=true</code>면 자동으로 active=0</li>



<li>다음날 <code>09:01</code>에 자동으로 active=1 복구 (옵션)</li>
</ul>



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



<h1 class="wp-block-heading">10) 트러블슈팅</h1>



<h3 class="wp-block-heading">✅ config.json이 없습니다</h3>



<ul class="wp-block-list">
<li><code>main.py</code>와 같은 폴더에 <code>config.json</code>이 있는지 확인</li>
</ul>



<pre class="wp-block-preformatted">ls -al</pre>



<h3 class="wp-block-heading">✅ state.json JSONDecodeError(Extra data)</h3>



<ul class="wp-block-list">
<li>파일이 깨진 것 → <code>{}</code>로 초기화</li>
</ul>



<pre class="wp-block-preformatted">cp state.json state.json.bak<br>echo "{}" &gt; state.json</pre>



<h3 class="wp-block-heading">✅ 알림이 안 오면</h3>



<ul class="wp-block-list">
<li>webhook URL 오타/토큰 확인</li>



<li>방화벽/SSL 문제 여부 확인</li>



<li>logs 확인</li>
</ul>



<pre class="wp-block-preformatted">tail -n 200 jusik.log<br></pre>
<p>게시물 <a href="https://howinfo.kr/orange-pi-5%eb%a1%9c-%ec%a3%bc%ec%8b%9d-etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%86%90%ec%a0%88-%eb%a6%ac%ec%8a%a4%ed%81%ac-%eb%a0%88%eb%b2%a8-%ec%95%8c/">Orange Pi 5로 “주식/ETF 트레일링 스탑 + 손절 + 리스크 레벨” 알림봇 만들기 (Synology Chat 웹훅, 10~30분 자동 주기)_버전 두번째 full 소스</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/orange-pi-5%eb%a1%9c-%ec%a3%bc%ec%8b%9d-etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%86%90%ec%a0%88-%eb%a6%ac%ec%8a%a4%ed%81%ac-%eb%a0%88%eb%b2%a8-%ec%95%8c/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<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>
		<item>
		<title>📈 파이썬 주식 봇 데이터를 DB에 저장하고 웹 대시보드로 보는 방법 (SQLite + Streamlit)</title>
		<link>https://howinfo.kr/%f0%9f%93%88-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%a5%bc-db%ec%97%90-%ec%a0%80%ec%9e%a5%ed%95%98%ea%b3%a0-%ec%9b%b9-%eb%8c%80%ec%8b%9c%eb%b3%b4/</link>
					<comments>https://howinfo.kr/%f0%9f%93%88-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%a5%bc-db%ec%97%90-%ec%a0%80%ec%9e%a5%ed%95%98%ea%b3%a0-%ec%9b%b9-%eb%8c%80%ec%8b%9c%eb%b3%b4/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Wed, 04 Feb 2026 06:26:21 +0000</pubDate>
				<category><![CDATA[자동화]]></category>
		<category><![CDATA[ai자동화]]></category>
		<category><![CDATA[sqlite]]></category>
		<category><![CDATA[streamlit]]></category>
		<category><![CDATA[주식그래프]]></category>
		<category><![CDATA[주식자동화]]></category>
		<category><![CDATA[파이썬주식봇]]></category>
		<category><![CDATA[포토폴리오대시보드]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1257</guid>

					<description><![CDATA[<p>Python 주식 자동화 봇을 운영하고 있다면, 이제는 ‘기록’을 남겨야 할 때입니다.매일 계산되는 손익, 수익률, 자산 변화… Chat 알림으로만 보지 말고...</p>
<p>게시물 <a href="https://howinfo.kr/%f0%9f%93%88-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%a5%bc-db%ec%97%90-%ec%a0%80%ec%9e%a5%ed%95%98%ea%b3%a0-%ec%9b%b9-%eb%8c%80%ec%8b%9c%eb%b3%b4/">📈 파이썬 주식 봇 데이터를 DB에 저장하고 웹 대시보드로 보는 방법 (SQLite + Streamlit)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p><strong>Python 주식 자동화 봇을 운영하고 있다면, 이제는 ‘기록’을 남겨야 할 때입니다.</strong><br>매일 계산되는 손익, 수익률, 자산 변화… Chat 알림으로만 보지 말고 <strong>그래프로 쌓아보세요.</strong></p>



<p></p>



<h2 class="wp-block-heading">✅ 왜 DB에 저장해야 할까?</h2>



<p>많은 분들이 이런 자동화 봇을 만듭니다.</p>



<ul class="wp-block-list">
<li>네이버/증권 API로 주가 조회</li>



<li>보유 종목 수익률 계산</li>



<li>Synology Chat, Slack, 텔레그램으로 알림 전송</li>
</ul>



<p>하지만 시간이 지나면 이런 생각이 들죠.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>“어제보다 오늘 얼마나 늘었지?”<br>“한 달 동안 수익률 추이가 어땠지?”<br>“어떤 종목이 실제로 수익에 가장 기여했지?”</p>
</blockquote>



<p>👉 <strong>알림 메시지로는 절대 알 수 없습니다.</strong><br>👉 <strong>시계열 데이터(History)가 필요합니다.</strong></p>



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



<h2 class="wp-block-heading">🧠 해결 방법 구조</h2>



<p>가장 간단하고 강력한 구조는 아래입니다.</p>



<pre class="wp-block-code"><code>&#91;파이썬 주식 봇]
        ↓
   SQLite DB 저장
        ↓
 Streamlit 웹 대시보드
</code></pre>



<p>이 방법의 장점은:</p>



<ul class="wp-block-list">
<li>별도 서버 불필요</li>



<li>DB는 파일 하나 (백업 쉬움)</li>



<li>오렌지파이 / 라즈베리파이에서도 충분히 동작</li>



<li>웹에서 그래프로 바로 확인 가능</li>
</ul>



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



<h2 class="wp-block-heading">🗄️ 1단계 — SQLite DB 설계</h2>



<p>봇이 실행될 때마다 2가지 정보를 저장합니다.</p>



<h3 class="wp-block-heading">① 포트폴리오 전체 요약 (1행)</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>항목</th><th>설명</th></tr></thead><tbody><tr><td>ts</td><td>실행 시각</td></tr><tr><td>total_value_krw</td><td>총 평가금액</td></tr><tr><td>total_invested_krw</td><td>총 매입금액</td></tr><tr><td>total_pl_krw</td><td>총 손익</td></tr><tr><td>total_roi_pct</td><td>총 수익률</td></tr></tbody></table></figure>



<h3 class="wp-block-heading">② 종목별 스냅샷 (종목 수 만큼)</h3>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>항목</th><th>설명</th></tr></thead><tbody><tr><td>ts</td><td>실행 시각</td></tr><tr><td>name</td><td>종목명</td></tr><tr><td>qty</td><td>보유 수량</td></tr><tr><td>avg_buy</td><td>평균 매입가</td></tr><tr><td>price</td><td>현재가</td></tr><tr><td>market_value_krw</td><td>평가금액</td></tr><tr><td>pl_krw</td><td>손익</td></tr><tr><td>roi_pct</td><td>수익률</td></tr></tbody></table></figure>



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



<h2 class="wp-block-heading">💾 SQLite 저장 코드 (봇에 추가)</h2>



<pre class="wp-block-code"><code>import sqlite3
from datetime import datetime

DB_PATH = "/home/orangepi/python/data/portfolio.db"

def init_db():
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()

    cur.execute("""
    CREATE TABLE IF NOT EXISTS portfolio_snapshot (
      ts TEXT PRIMARY KEY,
      total_value_krw REAL,
      total_invested_krw REAL,
      total_pl_krw REAL,
      total_roi_pct REAL
    )
    """)

    cur.execute("""
    CREATE TABLE IF NOT EXISTS holding_snapshot (
      ts TEXT,
      name TEXT,
      qty REAL,
      avg_buy REAL,
      price REAL,
      market_value_krw REAL,
      pl_krw REAL,
      roi_pct REAL,
      PRIMARY KEY (ts, name)
    )
    """)
    con.commit()
    con.close()
</code></pre>



<p>봇이 계산을 마친 시점에 다음 함수 호출만 추가하면 됩니다.</p>



<pre class="wp-block-code"><code>def save_snapshot(summary, holdings):
    ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()

    cur.execute("""
    INSERT OR REPLACE INTO portfolio_snapshot VALUES (?,?,?,?,?)
    """, (ts,
          summary&#91;"total_value_krw"],
          summary&#91;"total_invested_krw"],
          summary&#91;"total_pl_krw"],
          summary&#91;"total_roi_pct"]))

    for h in holdings:
        cur.execute("""
        INSERT OR REPLACE INTO holding_snapshot VALUES (?,?,?,?,?,?,?,?)
        """, (ts, h&#91;"name"], h&#91;"qty"], h&#91;"avg_buy"],
              h&#91;"price"], h&#91;"market_value_krw"],
              h&#91;"pl_krw"], h&#91;"roi_pct"]))

    con.commit()
    con.close()
</code></pre>



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



<h2 class="wp-block-heading">🌐 2단계 — Streamlit 웹 대시보드 만들기</h2>



<p>Streamlit은 파이썬으로 웹을 만드는 가장 쉬운 도구입니다.</p>



<p>설치:</p>



<pre class="wp-block-code"><code>pip install streamlit pandas
</code></pre>



<p><code>dashboard.py</code> 생성:</p>



<pre class="wp-block-code"><code>import sqlite3, pandas as pd
import streamlit as st

DB_PATH = "/home/orangepi/python/data/portfolio.db"

st.title("📈 포트폴리오 대시보드")

con = sqlite3.connect(DB_PATH)
snap = pd.read_sql_query("SELECT * FROM portfolio_snapshot ORDER BY ts", con)
hold = pd.read_sql_query("SELECT * FROM holding_snapshot ORDER BY ts", con)
con.close()

st.subheader("총 평가금액 추이")
st.line_chart(snap.set_index("ts")&#91;"total_value_krw"])

st.subheader("총 손익 추이")
st.line_chart(snap.set_index("ts")&#91;"total_pl_krw"])

st.subheader("최근 종목별 손익")
latest = hold&#91;"ts"].max()
st.dataframe(
    hold&#91;hold&#91;"ts"] == latest].sort_values("pl_krw", ascending=False)
)
</code></pre>



<p>실행:</p>



<pre class="wp-block-code"><code>streamlit run dashboard.py --server.port 8501
</code></pre>



<p>이제 브라우저에서 접속하면 <strong>주식 손익 그래프 웹페이지</strong>가 열립니다.</p>



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



<h2 class="wp-block-heading">📊 이렇게 보입니다</h2>



<figure class="wp-block-image"><img decoding="async" src="https://miro.medium.com/1%2AtpZuypCypENZw1eq8ijWCA.png" alt="https://miro.medium.com/1%2AtpZuypCypENZw1eq8ijWCA.png"/></figure>



<figure class="wp-block-image"><img decoding="async" src="https://images.prismic.io/turing/65a53cdf7a5e8b1120d5887f_image1_11zon_c0821d08b2.webp?auto=format%2Ccompress" alt="https://images.prismic.io/turing/65a53cdf7a5e8b1120d5887f_image1_11zon_c0821d08b2.webp?auto=format%2Ccompress"/></figure>



<figure class="wp-block-image"><img decoding="async" src="https://miro.medium.com/v2/resize%3Afit%3A1400/1%2A63VrsZO18fBTDmCdnbiNiw.gif" alt="https://miro.medium.com/v2/resize%3Afit%3A1400/1%2A63VrsZO18fBTDmCdnbiNiw.gif"/></figure>



<p>4</p>



<ul class="wp-block-list">
<li>총 자산 증가 추이</li>



<li>손익 변동 그래프</li>



<li>종목별 수익 기여도</li>



<li>자산 비중 파이차트 (추가 가능)</li>
</ul>



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



<h2 class="wp-block-heading">🚀 이 방식의 진짜 장점</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>기존</th><th>개선 후</th></tr></thead><tbody><tr><td>Chat 알림으로 그때그때 확인</td><td>시간 흐름에 따른 데이터 축적</td></tr><tr><td>오늘 수익만 확인</td><td>한 달/세 달/1년 추이 분석</td></tr><tr><td>감으로 투자 판단</td><td>데이터 기반 판단</td></tr><tr><td>로그 파일</td><td>웹 대시보드</td></tr></tbody></table></figure>



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



<h2 class="wp-block-heading">✨ 확장 아이디어</h2>



<ul class="wp-block-list">
<li>주간/월간 리포트를 자동으로 PNG 그래프로 생성 후 Chat 전송</li>



<li>Grafana/Metabase 연결</li>



<li>종목별 ROI 히스토리 그래프</li>



<li>자산군(ETF/주식/금) 비중 변화 추이</li>
</ul>



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



<h2 class="wp-block-heading">✅ 마무리</h2>



<p>주식 자동화 봇을 만들었다면, 이제는 <strong>데이터를 쌓아야 할 단계</strong>입니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>기록되지 않는 수익률은, 결국 기억에만 남습니다.</p>
</blockquote>



<p>SQLite + Streamlit 조합은<br><strong>가볍고, 쉽고, 강력한 개인 투자자용 대시보드</strong>를 만드는 최고의 방법입니다.</p>



<p>지금 사용 중인 주식 봇에 단 50줄만 추가해보세요.<br>당신의 투자가 ‘데이터’로 보이기 시작합니다.</p>



<p>다음시간에 실시간 기록을 남기는 소스를 보겠습니다. </p>



<p></p>
<p>게시물 <a href="https://howinfo.kr/%f0%9f%93%88-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%a5%bc-db%ec%97%90-%ec%a0%80%ec%9e%a5%ed%95%98%ea%b3%a0-%ec%9b%b9-%eb%8c%80%ec%8b%9c%eb%b3%b4/">📈 파이썬 주식 봇 데이터를 DB에 저장하고 웹 대시보드로 보는 방법 (SQLite + Streamlit)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%f0%9f%93%88-%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%a3%bc%ec%8b%9d-%eb%b4%87-%eb%8d%b0%ec%9d%b4%ed%84%b0%eb%a5%bc-db%ec%97%90-%ec%a0%80%ec%9e%a5%ed%95%98%ea%b3%a0-%ec%9b%b9-%eb%8c%80%ec%8b%9c%eb%b3%b4/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
