<?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/category/it-tech/coding/feed/" rel="self" type="application/rss+xml" />
	<link>https://howinfo.kr/category/it-tech/coding/</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/category/it-tech/coding/</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>오렌지파이5로 주식·ETF 트레일링 스탑 알림 시스템 만들기</title>
		<link>https://howinfo.kr/%ec%98%a4%eb%a0%8c%ec%a7%80%ed%8c%8c%ec%9d%b45%eb%a1%9c-%ec%a3%bc%ec%8b%9d%c2%b7etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%95%8c%eb%a6%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%eb%a7%8c/</link>
					<comments>https://howinfo.kr/%ec%98%a4%eb%a0%8c%ec%a7%80%ed%8c%8c%ec%9d%b45%eb%a1%9c-%ec%a3%bc%ec%8b%9d%c2%b7etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%95%8c%eb%a6%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%eb%a7%8c/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Wed, 25 Feb 2026 02:53:59 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[etf 자동화]]></category>
		<category><![CDATA[Synology chat webhook]]></category>
		<category><![CDATA[Trailing stop]]></category>
		<category><![CDATA[시놀로지 챗 웹훅]]></category>
		<category><![CDATA[알림봇]]></category>
		<category><![CDATA[오렌지파이5]]></category>
		<category><![CDATA[주식 자동화]]></category>
		<category><![CDATA[주식알림시스템]]></category>
		<category><![CDATA[트레일링 스탑]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=2098</guid>

					<description><![CDATA[<p>(Synology Chat 웹훅 + 종목별 트레일링% 설정 + 일일 요약 포함) 주식을 하다 보면 이런 생각이 듭니다. “최고 수익에서 10%만...</p>
<p>게시물 <a href="https://howinfo.kr/%ec%98%a4%eb%a0%8c%ec%a7%80%ed%8c%8c%ec%9d%b45%eb%a1%9c-%ec%a3%bc%ec%8b%9d%c2%b7etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%95%8c%eb%a6%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%eb%a7%8c/">오렌지파이5로 주식·ETF 트레일링 스탑 알림 시스템 만들기</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<h3 class="wp-block-heading">(Synology Chat 웹훅 + 종목별 트레일링% 설정 + 일일 요약 포함)</h3>



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



<h1 class="wp-block-heading">주식을 하다 보면 이런 생각이 듭니다.</h1>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>“최고 수익에서 10%만 빠지면 정리하고 싶은데…<br>내가 계속 보고 있을 수는 없잖아?”</p>
</blockquote>



<p>그래서 오렌지파이5에 <strong>상시 감시용 트레일링 알림 시스템</strong>을 만들었습니다.</p>



<p>✔ 일반주식 + ETF 모두 가능<br>✔ 종목별로 트레일링 % 다르게 설정<br>✔ 장중에만 매도 알림<br>✔ 장마감 후 하루 1번 요약<br>✔ 매도 알림 발생 시 자동 감시 해제</p>



<p>이 글에서는 <strong>실제 운영 가능한 코드 구조</strong>를 정리합니다.</p>



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



<h1 class="wp-block-heading">🧠 시스템 구조</h1>



<pre class="wp-block-preformatted">positions.csv   → 보유 종목 관리<br>config.json     → 시스템 설정<br>state.json      → 종목별 최고 수익(peak) 저장<br>main.py         → 전체 로직 실행</pre>



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



<h1 class="wp-block-heading">📊 핵심 로직 (트레일링 스탑)</h1>



<h3 class="wp-block-heading">1️⃣ 기본 계산</h3>



<pre class="wp-block-preformatted">entry_value   = 매수수량 × 매수가<br>current_value = 매수수량 × 현재가<br>peak_value    = max(기존 peak_value, current_value)</pre>



<h3 class="wp-block-heading">2️⃣ 트레일링 기준선</h3>



<pre class="wp-block-preformatted">stop_value = peak_value × (1 - trailing_pct)</pre>



<h3 class="wp-block-heading">3️⃣ 매도 알림 조건</h3>



<pre class="wp-block-preformatted">수익 구간에서만 적용<br>current_value &lt;= stop_value 이면 알림</pre>



<p>✔ 계속 오르면 peak가 올라감<br>✔ 기준선도 같이 올라감<br>✔ 최고점 대비 n% 하락 시 알림</p>



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



<h1 class="wp-block-heading">🗂 1️⃣ 보유 종목 파일 (positions.csv)</h1>



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



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>컬럼</th><th>설명</th></tr></thead><tbody><tr><td>code</td><td>6자리 종목코드</td></tr><tr><td>market</td><td>KS(코스피/ETF), KQ(코스닥)</td></tr><tr><td>qty</td><td>수량</td></tr><tr><td>buy_price</td><td>매수가</td></tr><tr><td>trailing_pct</td><td>종목별 트레일링 비율</td></tr><tr><td>active</td><td>1=감시, 0=감시중지</td></tr></tbody></table></figure>



<p>✔ 매도 알림 발생 시 active=0 자동 변경</p>



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



<h1 class="wp-block-heading">⚙ 2️⃣ 설정 파일 (config.json)</h1>



<pre class="wp-block-preformatted">{<br>  "interval_minutes": 30,<br>  "default_trailing_pct": 0.10,<br>  "synology_chat_webhook_url": "웹훅URL",  "market_open": "09:00",<br>  "market_close": "15:30",  "daily_summary_time": "15:35",  "auto_disable_on_alert": true<br>}</pre>



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



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



<pre class="wp-block-preformatted">python3 -m pip install --upgrade pip<br>python3 -m pip install yfinance requests</pre>



<p>✔ ARM 환경에서 가장 안정적<br>✔ FinanceDataReader 대신 yfinance 사용</p>



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



<h1 class="wp-block-heading">🧾 4️⃣ 전체 실행 코드 (주석 상세 버전)</h1>



<p>아래는 핵심 구조가 잘 보이도록 정리한 “설명형 소스”입니다.</p>



<pre class="wp-block-preformatted">"""<br>OrangePi5 트레일링 스탑 알림 시스템<br>- 주식 + ETF 지원<br>- 종목별 trailing_pct 적용<br>- 장중만 매도 알림<br>- 하루 1회 요약<br>- 매도 발생 시 active=0 자동 변경<br>"""import os<br>import json<br>import time<br>import csv<br>from datetime import datetime, timedelta, time as dtime<br>import requests<br>import yfinance as yf# -------------------------<br># 기본 경로 설정<br># -------------------------<br>BASE = 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")# -------------------------<br># 유틸 함수<br># -------------------------<br>def log(msg):<br>    print(f"[{datetime.now()}] {msg}")def fmt(x):<br>    return f"{x:,.0f}"def parse_hhmm(s):<br>    h, m = s.split(":")<br>    return dtime(int(h), int(m))# -------------------------<br># Synology Chat 알림<br># -------------------------<br>def send_chat(url, text):<br>    payload = json.dumps({"text": text}, ensure_ascii=False)<br>    requests.post(url, data={"payload": payload})# -------------------------<br># 가격 조회 (yfinance)<br># -------------------------<br>def get_price(code, market):<br>    ticker = f"{code}.{market}"<br>    hist = yf.Ticker(ticker).history(period="7d")<br>    return float(hist["Close"].iloc[-1])# -------------------------<br># 메인 실행<br># -------------------------<br>def main():<br>    config = json.load(open(CONFIG_PATH))<br>    state = json.load(open(STATE_PATH)) if os.path.exists(STATE_PATH) else {}    open_t = parse_hhmm(config["market_open"])<br>    close_t = parse_hhmm(config["market_close"])<br>    summary_t = parse_hhmm(config["daily_summary_time"])    while True:<br>        now = datetime.now()        # positions 읽기<br>        positions = list(csv.DictReader(open(POSITIONS_PATH, encoding="utf-8-sig")))<br>        positions = [p for p in positions if p.get("active","1") == "1"]        total_entry = total_current = total_peak = 0<br>        rows = []        for p in positions:<br>            code = p["code"]<br>            market = p.get("market","KS")<br>            qty = float(p["qty"])<br>            buy = float(p["buy_price"])<br>            trailing = float(p.get("trailing_pct") or config["default_trailing_pct"])            price = get_price(code, market)            entry = qty * buy<br>            current = qty * price            peak = state.get(code, {}).get("peak", entry)<br>            peak = max(peak, current)            state.setdefault(code, {})["peak"] = peak            # 트레일링 조건<br>            if open_t &lt;= now.time() &lt;= close_t:<br>                stop = peak * (1 - trailing)<br>                if current &lt;= stop:<br>                    send_chat(config["synology_chat_webhook_url"],<br>                              f"[매도 알림] {p['name']} 최고대비 {int(trailing*100)}% 하락")<br>                    if config["auto_disable_on_alert"]:<br>                        p["active"] = "0"            total_entry += entry<br>            total_current += current<br>            total_peak += peak            rows.append((p["name"], (current-entry)/entry*100))        # 하루 1회 요약<br>        if now.time() &gt;= summary_t:<br>            if state.get("summary_date") != now.strftime("%Y-%m-%d"):<br>                profit = total_current - total_entry<br>                send_chat(config["synology_chat_webhook_url"],<br>                          f"[일일 요약] 총 손익 {fmt(profit)}원")<br>                state["summary_date"] = now.strftime("%Y-%m-%d")        json.dump(state, open(STATE_PATH,"w"), indent=2)        log("루프 완료. 다음 실행 대기...")<br>        time.sleep(config["interval_minutes"]*60)if __name__ == "__main__":<br>    main()</pre>



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



<h1 class="wp-block-heading">📈 실행 중 로그 예시</h1>



<pre class="wp-block-preformatted">[2026-02-25 10:00] 가격 조회 시작<br>[2026-02-25 10:00] 삼성전자 peak 갱신<br>[2026-02-25 10:00] 트레일링 체크 완료<br>[2026-02-25 10:00] 루프 완료. 다음 실행 대기...</pre>



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



<h1 class="wp-block-heading">📌 운영하면서 느낀 점</h1>



<ul class="wp-block-list">
<li>지연 시세 기반이라 실시간 체결가 기반은 아님</li>



<li>그러나 구조 테스트 및 자동 감시 시스템에는 충분</li>



<li>실시간으로 확장하려면 가격 조회 부분만 API로 교체하면 됨</li>
</ul>



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



<h1 class="wp-block-heading">🚀 다음 확장 방향</h1>



<ul class="wp-block-list">
<li>증권사 API 연동</li>



<li>텔레그램/카카오톡 알림 추가</li>



<li>Streamlit 대시보드 추가</li>



<li>매수 전략 자동화</li>
</ul>



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



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



<p>이 시스템은</p>



<p>✔ 오렌지파이5 같은 저전력 장비에 적합<br>✔ 구조가 단순해서 유지보수 쉬움<br>✔ 나중에 실시간 API로 확장 가능</p>



<p>입니다.</p>



<figure class="wp-block-image size-full"><img decoding="async" width="855" height="433" src="https://howinfo.kr/wp-content/uploads/2026/02/sychat.png" alt="" class="wp-image-2102" srcset="https://howinfo.kr/wp-content/uploads/2026/02/sychat.png 855w, https://howinfo.kr/wp-content/uploads/2026/02/sychat-300x152.png 300w, https://howinfo.kr/wp-content/uploads/2026/02/sychat-768x389.png 768w" sizes="(max-width: 855px) 100vw, 855px" /></figure>
<p>게시물 <a href="https://howinfo.kr/%ec%98%a4%eb%a0%8c%ec%a7%80%ed%8c%8c%ec%9d%b45%eb%a1%9c-%ec%a3%bc%ec%8b%9d%c2%b7etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%95%8c%eb%a6%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%eb%a7%8c/">오렌지파이5로 주식·ETF 트레일링 스탑 알림 시스템 만들기</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ec%98%a4%eb%a0%8c%ec%a7%80%ed%8c%8c%ec%9d%b45%eb%a1%9c-%ec%a3%bc%ec%8b%9d%c2%b7etf-%ed%8a%b8%eb%a0%88%ec%9d%bc%eb%a7%81-%ec%8a%a4%ed%83%91-%ec%95%8c%eb%a6%bc-%ec%8b%9c%ec%8a%a4%ed%85%9c-%eb%a7%8c/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>파이썬 서버 로그 자동 분석 후 시놀리지 챗 알림 보내기</title>
		<link>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%84%9c%eb%b2%84-%eb%a1%9c%ea%b7%b8-%ec%9e%90%eb%8f%99-%eb%b6%84%ec%84%9d-%ed%9b%84-%ec%8b%9c%eb%86%80%eb%a6%ac%ec%a7%80-%ec%b1%97-%ec%95%8c%eb%a6%bc-%eb%b3%b4%eb%82%b4/</link>
					<comments>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%84%9c%eb%b2%84-%eb%a1%9c%ea%b7%b8-%ec%9e%90%eb%8f%99-%eb%b6%84%ec%84%9d-%ed%9b%84-%ec%8b%9c%eb%86%80%eb%a6%ac%ec%a7%80-%ec%b1%97-%ec%95%8c%eb%a6%bc-%eb%b3%b4%eb%82%b4/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Fri, 13 Feb 2026 06:16:12 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[ai자동화]]></category>
		<category><![CDATA[DevOps 기초]]></category>
		<category><![CDATA[it운영 자동화]]></category>
		<category><![CDATA[nas자동화]]></category>
		<category><![CDATA[로그 자동분석]]></category>
		<category><![CDATA[서버 장애감지]]></category>
		<category><![CDATA[시놀로지 챗 알림]]></category>
		<category><![CDATA[파이썬 서버 모니터링]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1634</guid>

					<description><![CDATA[<p>Python으로 장애 감지 시스템 만들기 (실전 구성) 📌 왜 로그 자동 알림이 필요할까? 서버는 보통 이렇게 망가집니다. 그런데 문제는, 대부분...</p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%84%9c%eb%b2%84-%eb%a1%9c%ea%b7%b8-%ec%9e%90%eb%8f%99-%eb%b6%84%ec%84%9d-%ed%9b%84-%ec%8b%9c%eb%86%80%eb%a6%ac%ec%a7%80-%ec%b1%97-%ec%95%8c%eb%a6%bc-%eb%b3%b4%eb%82%b4/">파이썬 서버 로그 자동 분석 후 시놀리지 챗 알림 보내기</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>Python으로 장애 감지 시스템 만들기 (실전 구성)</p>



<h2 class="wp-block-heading">📌 왜 로그 자동 알림이 필요할까?</h2>



<p>서버는 보통 이렇게 망가집니다.</p>



<ul class="wp-block-list">
<li>CPU가 천천히 올라감</li>



<li>디스크가 가득 참</li>



<li>nginx에서 500 에러 증가</li>



<li>특정 서비스가 반복적으로 죽음</li>
</ul>



<p>그런데 문제는,</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>대부분 “나중에” 알게 됩니다.</p>
</blockquote>



<p>그래서 필요한 것이 바로</p>



<p>👉 <strong>로그를 자동 분석하다가 이상 징후가 나오면 즉시 알림 전송</strong></p>



<p>이번 글에서는:</p>



<ul class="wp-block-list">
<li>Python으로 로그 실시간 감시</li>



<li>에러 패턴 발견 시</li>



<li>Synology Chat으로 자동 알림 전송</li>
</ul>



<p>구성을 완성합니다.</p>



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



<h1 class="wp-block-heading">🧠 전체 구조</h1>



<pre class="wp-block-code"><code>&#91;서버 로그 파일]
        ↓
&#91;Python 프로그램이 실시간 감시]
        ↓
&#91;에러 패턴 발견]
        ↓
&#91;Synology Chat Webhook 전송]
        ↓
&#91;채널에 알림 도착]
</code></pre>



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



<h1 class="wp-block-heading">🛠 1단계: 시놀로지 챗 Webhook 생성</h1>



<h3 class="wp-block-heading">① Synology Chat 접속</h3>



<h3 class="wp-block-heading">② 우측 상단 프로필 → 통합(Integration)</h3>



<h3 class="wp-block-heading">③ &#8220;Incoming Webhooks&#8221; 생성</h3>



<h3 class="wp-block-heading">④ 채널 선택</h3>



<h3 class="wp-block-heading">⑤ 생성된 Webhook URL 복사</h3>



<p>URL 예시:</p>



<pre class="wp-block-code"><code>https:&#47;&#47;NAS주소:5001/webapi/entry.cgi?api=SYNO.Chat.External&amp;method=incoming&amp;version=2&amp;token=토큰값
</code></pre>



<p>이 URL을 Python 코드에 붙여 넣습니다.</p>



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



<h1 class="wp-block-heading">🧩 2단계: Python 로그 감시 프로그램</h1>



<p>파일명 예:</p>



<pre class="wp-block-code"><code>log_watch_to_synochat.py
</code></pre>



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



<h2 class="wp-block-heading">📜 전체 코드 (실전용)</h2>



<pre class="wp-block-code"><code>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import time
import json
import re
import hashlib
from datetime import datetime
import requests

# ====== 설정 ======
SYNCHAT_WEBHOOK_URL = "여기에_웹훅_URL_입력"

LOG_FILES = &#91;
    "/var/log/syslog",
    # "/var/log/nginx/error.log",
]

ERROR_PATTERNS = &#91;
    r"\bERROR\b",
    r"\bCRITICAL\b",
    r"\bFATAL\b",
    r"Exception",
    r"\b5\d\d\b"
]

DEDUP_WINDOW_SEC = 300
VERIFY_SSL = True
# ===================


def send_synology_chat(text):
    payload_obj = {"text": text}
    data = {"payload": json.dumps(payload_obj, ensure_ascii=False)}
    response = requests.post(
        SYNCHAT_WEBHOOK_URL,
        data=data,
        timeout=10,
        verify=VERIFY_SSL
    )
    response.raise_for_status()


def tail_follow(path):
    with open(path, "r", encoding="utf-8", errors="replace") as f:
        f.seek(0, os.SEEK_END)
        while True:
            line = f.readline()
            if not line:
                time.sleep(0.2)
                continue
            yield line.strip()


def main():
    compiled_patterns = &#91;re.compile(p, re.IGNORECASE) for p in ERROR_PATTERNS]
    last_sent = {}

    watchers = &#91;(path, tail_follow(path)) for path in LOG_FILES]

    while True:
        for path, generator in watchers:
            try:
                line = next(generator)
            except StopIteration:
                continue

            for pattern in compiled_patterns:
                if pattern.search(line):
                    now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
                    message = f"🚨 서버 로그 에러 감지\n시간: {now}\n파일: {path}\n내용: {line}"

                    key = hashlib.sha1(message.encode()).hexdigest()
                    if key not in last_sent or time.time() - last_sent&#91;key] &gt; DEDUP_WINDOW_SEC:
                        try:
                            send_synology_chat(message)
                            last_sent&#91;key] = time.time()
                            print("전송 완료:", message)
                        except Exception as e:
                            print("전송 실패:", e)

        time.sleep(0.05)


if __name__ == "__main__":
    main()
</code></pre>



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



<h1 class="wp-block-heading">🚀 3단계: 실행 방법</h1>



<h3 class="wp-block-heading">1️⃣ requests 설치</h3>



<pre class="wp-block-code"><code>pip3 install requests
</code></pre>



<h3 class="wp-block-heading">2️⃣ 실행</h3>



<pre class="wp-block-code"><code>python3 log_watch_to_synochat.py
</code></pre>



<p>※ <code>/var/log</code> 접근은 root 권한이 필요할 수 있습니다.</p>



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



<h1 class="wp-block-heading">🔄 4단계: systemd로 자동 실행 설정</h1>



<p>운영 환경이라면 백그라운드 실행이 필요합니다.</p>



<p>서비스 파일 생성:</p>



<pre class="wp-block-code"><code>sudo nano /etc/systemd/system/logwatch.service
</code></pre>



<p>내용:</p>



<pre class="wp-block-code"><code>&#91;Unit]
Description=Log Watch to Synology Chat
After=network.target

&#91;Service]
ExecStart=/usr/bin/python3 /경로/log_watch_to_synochat.py
Restart=always
User=root

&#91;Install]
WantedBy=multi-user.target
</code></pre>



<p>활성화:</p>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable logwatch
sudo systemctl start logwatch
</code></pre>



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



<h1 class="wp-block-heading">⚙ 실무 업그레이드 아이디어</h1>



<p>✔ 5분 동안 에러 10회 이상일 때만 전송<br>✔ 에러 유형별로 메시지 구분<br>✔ 최근 10줄 로그 묶어서 전송<br>✔ Docker 로그 감시 추가<br>✔ nginx 전용 패턴 구성</p>



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



<h1 class="wp-block-heading">🎯 실제 운영에서 얻는 효과</h1>



<ul class="wp-block-list">
<li>장애 발생 즉시 인지</li>



<li>새벽 시간 장애 대응 가능</li>



<li>로그 확인 시간 단축</li>



<li>운영 신뢰도 상승</li>
</ul>



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



<h1 class="wp-block-heading">📋 체크리스트</h1>



<p>✔ Webhook 생성 완료<br>✔ Python 실행 성공<br>✔ 에러 발생 시 Chat 알림 수신<br>✔ 중복 알림 방지 정상 동작<br>✔ systemd 등록 완료</p>



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



<h1 class="wp-block-heading">🔥 이 구성의 핵심 장점</h1>



<ul class="wp-block-list">
<li>비용 0원</li>



<li>외부 SaaS 불필요</li>



<li>NAS 기반 완전 자가 운영 가능</li>



<li>가볍고 안정적</li>
</ul>



<p></p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%84%9c%eb%b2%84-%eb%a1%9c%ea%b7%b8-%ec%9e%90%eb%8f%99-%eb%b6%84%ec%84%9d-%ed%9b%84-%ec%8b%9c%eb%86%80%eb%a6%ac%ec%a7%80-%ec%b1%97-%ec%95%8c%eb%a6%bc-%eb%b3%b4%eb%82%b4/">파이썬 서버 로그 자동 분석 후 시놀리지 챗 알림 보내기</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac-%ec%84%9c%eb%b2%84-%eb%a1%9c%ea%b7%b8-%ec%9e%90%eb%8f%99-%eb%b6%84%ec%84%9d-%ed%9b%84-%ec%8b%9c%eb%86%80%eb%a6%ac%ec%a7%80-%ec%b1%97-%ec%95%8c%eb%a6%bc-%eb%b3%b4%eb%82%b4/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</title>
		<link>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/</link>
					<comments>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Thu, 12 Feb 2026 02:25:07 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[EdgeTTS]]></category>
		<category><![CDATA[sysemd]]></category>
		<category><![CDATA[tts]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[알람시계]]></category>
		<category><![CDATA[오렌지파이5]]></category>
		<category><![CDATA[자동화]]></category>
		<category><![CDATA[파이썬]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1594</guid>

					<description><![CDATA[<p>아침에 알람이 울리긴 하는데…“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠. 이번 글에서는 오렌지파이5 + Ubuntu 환경에서, 파이썬으로...</p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/">파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<hr class="wp-block-separator has-alpha-channel-opacity"/>



<h1 class="wp-block-heading"></h1>



<p>아침에 알람이 울리긴 하는데…<br>“몇 시인지 말로 알려주면 진짜 바로 일어나겠는데?” 싶을 때가 있죠.</p>



<p>이번 글에서는 <strong>오렌지파이5 + Ubuntu</strong> 환경에서, 파이썬으로 <strong>말하는 음성 알람</strong>을 만드는 방법을 정리했습니다.</p>



<ul class="wp-block-list">
<li>06:00부터 10분 단위로 06:30까지</li>



<li>“주인님 일어나세요. 현재 시간 06시 10분입니다.” 같은 문장을 <strong>TTS로 말해주고</strong></li>



<li>설정파일 1개로 <strong>매일/평일/1회 + 공휴일 제외</strong>까지 제어하고</li>



<li><strong>EdgeTTS 캐시(재생 빠름)</strong> + <strong>중복 재생 방지(안전)</strong> + **systemd 자동 실행(운영 편함)**까지 묶었습니다.</li>
</ul>



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



<h2 class="wp-block-heading">목표 동작 요약</h2>



<ul class="wp-block-list">
<li>알람 시간: <code>06:00</code>, <code>06:10</code>, <code>06:20</code>, <code>06:30</code></li>



<li>출력: 스피커로 음성 재생(mp3)</li>



<li>스케줄 방식: 설정파일(JSON) 기반</li>



<li>운영 안정성:
<ul class="wp-block-list">
<li>같은 분에 두 번 울리는 것 방지(상태파일 기록)</li>



<li>TTS는 캐시(mp3 재사용)로 속도/안정성 개선</li>



<li>systemd로 부팅 후 자동 실행</li>
</ul>
</li>
</ul>



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



<h2 class="wp-block-heading">준비물</h2>



<ul class="wp-block-list">
<li>Orange Pi 5 (또는 Ubuntu 머신)</li>



<li>Ubuntu 22.04/24.04 계열</li>



<li>스피커(3.5mm/USB/블루투스 등)</li>
</ul>



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



<h2 class="wp-block-heading">설치(필수 패키지)</h2>



<pre class="wp-block-code"><code>sudo apt update
sudo apt install -y python3-pip mpg123
pip3 install edge-tts holidays
</code></pre>



<ul class="wp-block-list">
<li><code>edge-tts</code> : 텍스트 → 음성(mp3) 생성</li>



<li><code>mpg123</code> : mp3를 바로 재생(가볍고 안정적)</li>



<li><code>holidays</code> : 한국 공휴일 제외(선택처럼 보이지만 “휴일 제외”를 쓰려면 필요)</li>
</ul>



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



<h2 class="wp-block-heading">1) 설정파일 1개로 알람 규칙 관리하기</h2>



<p>프로젝트 폴더를 만들고, 설정파일을 준비합니다.</p>



<pre class="wp-block-code"><code>mkdir -p ~/edge_alarm
cd ~/edge_alarm
nano alarm_config.json
</code></pre>



<h3 class="wp-block-heading"><code>alarm_config.json</code></h3>



<pre class="wp-block-code"><code>{
  "mode": "weekdays",
  "times": &#91;"06:00", "06:10", "06:20", "06:30"],
  "message_template": "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.",
  "voice": "ko-KR-SunHiNeural",
  "rate": "+0%",
  "volume": 100,
  "exclude_public_holidays": true,
  "country_holidays": "KR",
  "once_date": "2026-02-12"
}
</code></pre>



<h3 class="wp-block-heading">핵심 옵션 설명</h3>



<ul class="wp-block-list">
<li><code>mode</code>
<ul class="wp-block-list">
<li><code>daily</code> : 매일</li>



<li><code>weekdays</code> : 평일만(토/일 제외)</li>



<li><code>once</code> : 특정 날짜 <code>once_date</code>에만 1회 실행</li>
</ul>
</li>



<li><code>times</code> : 울릴 시간을 배열로 관리</li>



<li><code>message_template</code> : <code>{hh}</code>, <code>{mm}</code>가 현재 시각으로 자동 치환</li>



<li><code>exclude_public_holidays</code> : 공휴일 제외 여부</li>



<li><code>country_holidays</code> : 한국은 <code>"KR"</code></li>
</ul>



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



<h2 class="wp-block-heading">2) 파이썬 실행 코드(alarm_tts.py)</h2>



<p>이 코드는 아래 3가지를 “운영 가능한 수준”으로 묶는 게 포인트입니다.</p>



<ol class="wp-block-list">
<li><strong>EdgeTTS 캐시</strong>: 같은 문장은 mp3를 저장해 재사용</li>



<li><strong>중복방지</strong>: <code>YYYY-MM-DD_HH:MM</code> 키로 “이미 울림” 기록</li>



<li><strong>자동실행</strong>: systemd로 부팅 시 자동 기동</li>
</ol>



<p>아래 파일을 저장하세요.</p>



<pre class="wp-block-code"><code>nano alarm_tts.py
chmod +x alarm_tts.py
</code></pre>



<h3 class="wp-block-heading"><code>alarm_tts.py</code> (한글 상세 주석 포함)</h3>



<pre class="wp-block-code"><code>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""
&#91;EdgeTTS 음성 알람 스크립트]
- 설정 파일(alarm_config.json) 하나만 수정해서 운영 가능
- EdgeTTS로 MP3 생성 후 스피커로 재생(mpg123 사용)
- MP3 캐시 저장(같은 문장 재사용) -&gt; 빠르고 안정적
- 상태 파일 기록(같은 분 중복 재생 방지)
- systemd 서비스로 등록하면 부팅 후 자동 실행 가능
"""

import asyncio
import json
import os
import sys
import time
import hashlib
import subprocess
from datetime import datetime, date, timedelta

# EdgeTTS 라이브러리 import
try:
    import edge_tts
except ImportError:
    print("edge-tts가 설치되어 있지 않습니다. `pip3 install edge-tts`를 실행하세요.")
    sys.exit(1)

# 공휴일 제외 기능을 위한 라이브러리(없으면 공휴일 판단 기능이 비활성)
try:
    import holidays as holidays_lib
except ImportError:
    holidays_lib = None

# -----------------------------
# 파일 경로(환경변수로 오버라이드 가능)
# -----------------------------
CONFIG_PATH = os.environ.get("ALARM_CONFIG", "./alarm_config.json")  # 설정 파일
STATE_PATH  = os.environ.get("ALARM_STATE", "./alarm_state.json")    # 중복방지 상태 파일
CACHE_DIR   = os.environ.get("ALARM_CACHE", "./tts_cache")           # TTS mp3 캐시 폴더


def load_json(path: str, default):
    """JSON 파일 로딩. 파일이 없으면 default 반환"""
    if not os.path.exists(path):
        return default
    with open(path, "r", encoding="utf-8") as f:
        return json.load(f)


def save_json(path: str, obj):
    """
    JSON 저장을 안전하게 하기 위한 방식
    - 임시 파일(.tmp)에 먼저 저장한 뒤 os.replace로 교체
    - 저장 중 전원 문제 등으로 파일이 깨질 위험을 줄임
    """
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        json.dump(obj, f, ensure_ascii=False, indent=2)
    os.replace(tmp, path)


def ensure_dir(p: str):
    """폴더가 없으면 생성"""
    os.makedirs(p, exist_ok=True)


def is_public_holiday(d: date, country_code: str) -&gt; bool:
    """
    특정 국가 공휴일 여부 판단
    - holidays 라이브러리가 없으면 False 처리(공휴일 제외 비활성)
    """
    if holidays_lib is None:
        return False

    try:
        h = holidays_lib.country_holidays(country_code)
        return d in h
    except Exception:
        return False


def should_run_today(cfg: dict, today: date) -&gt; bool:
    """
    오늘 알람을 동작시킬지 판단
    - mode(daily/weekdays/once)
    - 공휴일 제외 옵션
    """
    mode = cfg.get("mode", "daily").lower()

    # once 모드: 특정 날짜에만 동작
    if mode == "once":
        once_date = cfg.get("once_date")
        if not once_date:
            return False
        try:
            od = datetime.strptime(once_date, "%Y-%m-%d").date()
            return today == od
        except ValueError:
            return False

    # weekdays 모드: 토/일이면 동작 안 함
    if mode == "weekdays":
        if today.weekday() &gt;= 5:
            return False

    # 공휴일 제외 옵션
    if cfg.get("exclude_public_holidays", False):
        cc = cfg.get("country_holidays", "KR")
        if is_public_holiday(today, cc):
            return False

    return True


def parse_times(cfg: dict):
    """
    설정 times(&#91;"06:00","06:10"...])를 (hh,mm) 튜플 리스트로 변환
    - 잘못된 값은 무시
    - 중복 제거 + 정렬
    """
    times = cfg.get("times", &#91;])
    parsed = &#91;]
    for t in times:
        try:
            hh, mm = t.split(":")
            parsed.append((int(hh), int(mm)))
        except Exception:
            pass
    return sorted(set(parsed))


def make_message(cfg: dict, now: datetime) -&gt; str:
    """설정 템플릿에서 멘트 생성({hh},{mm} 치환)"""
    tpl = cfg.get("message_template", "주인님 일어나세요. 현재 시간 {hh}시 {mm}분입니다.")
    return tpl.format(hh=now.strftime("%H"), mm=now.strftime("%M"))


def tts_cache_path(text: str, voice: str, rate: str) -&gt; str:
    """
    같은 텍스트/목소리/속도 조합은 mp3를 재사용하기 위해 해시 파일명으로 캐시 저장
    """
    key = f"{voice}|{rate}|{text}".encode("utf-8")
    h = hashlib.sha256(key).hexdigest()&#91;:24]
    return os.path.join(CACHE_DIR, f"{h}.mp3")


async def synthesize_mp3(text: str, voice: str, rate: str, out_path: str):
    """EdgeTTS로 mp3 생성(비동기)"""
    communicate = edge_tts.Communicate(text=text, voice=voice, rate=rate)
    await communicate.save(out_path)


def play_mp3(path: str, volume: int = 100):
    """
    mpg123로 mp3 재생
    - volume(0~100)을 gain으로 완만하게 반영
    """
    gain = max(0, min(32768, int(volume) * 80))
    subprocess.run(&#91;"mpg123", "-q", "-f", str(gain), path], check=False)


def minute_key(d: date, hh: int, mm: int) -&gt; str:
    """중복방지 키: YYYY-MM-DD_HH:MM"""
    return f"{d.isoformat()}_{hh:02d}:{mm:02d}"


def next_trigger_datetime(now: datetime, times):
    """
    현재 시각 기준으로 다음 알람 시각 찾기
    - 오늘~모레까지 탐색(안전장치)
    """
    for day_offset in range(0, 3):
        base = now.date() + timedelta(days=day_offset)
        for hh, mm in times:
            dt = datetime.combine(base, datetime.min.time()).replace(hour=hh, minute=mm)
            if dt &gt; now:
                return dt
    return None


async def main():
    """메인 루프"""
    ensure_dir(CACHE_DIR)

    cfg = load_json(CONFIG_PATH, default={})
    state = load_json(STATE_PATH, default={"fired": {}})

    times = parse_times(cfg)
    if not times:
        print("times 설정이 비어 있거나 형식이 잘못되었습니다.")
        return

    voice = cfg.get("voice", "ko-KR-SunHiNeural")
    rate = cfg.get("rate", "+0%")
    volume = int(cfg.get("volume", 100))

    print(f"&#91;alarm] 시작 mode={cfg.get('mode')} times={cfg.get('times')} voice={voice}")

    while True:
        now = datetime.now()
        today = now.date()

        # 오늘 동작 조건이 아니면 내일 새벽까지 대기
        if not should_run_today(cfg, today):
            tomorrow = datetime.combine(today + timedelta(days=1), datetime.min.time()).replace(minute=1)
            sleep_sec = max(5, int((tomorrow - now).total_seconds()))
            print(f"&#91;alarm] 오늘({today}) 스킵. {sleep_sec}초 후 재확인")
            time.sleep(sleep_sec)
            continue

        # 다음 알람 시각 계산
        nxt = next_trigger_datetime(now, times)
        if not nxt:
            time.sleep(10)
            continue

        # 다음 알람까지 대기(너무 길게 한번에 sleep하지 않도록 최대 60초 단위로 쪼갬)
        sleep_sec = (nxt - now).total_seconds()
        if sleep_sec &gt; 1:
            time.sleep(min(60, sleep_sec))
            continue

        # 중복방지: 같은 분에 이미 울렸으면 스킵
        k = minute_key(nxt.date(), nxt.hour, nxt.minute)
        if state.get("fired", {}).get(k):
            time.sleep(1)
            continue

        # 현재 시간 안내가 정확하도록 "울리는 순간"의 시간을 기준으로 멘트 생성
        speak_time = datetime.now()
        text = make_message(cfg, speak_time)

        # 캐시 mp3가 있으면 재사용, 없으면 새로 생성
        cache_path = tts_cache_path(text, voice, rate)
        if not os.path.exists(cache_path):
            try:
                await synthesize_mp3(text, voice, rate, cache_path)
            except Exception as e:
                print("&#91;alarm] TTS 생성 실패:", e)
                time.sleep(2)
                continue

        print(f"&#91;alarm] 울림 {k} =&gt; {text}")
        play_mp3(cache_path, volume=volume)

        # 상태 기록(이 분에는 이미 울렸음)
        state.setdefault("fired", {})&#91;k] = True
        save_json(STATE_PATH, state)

        # once 모드면 오늘 남은 알람이 없을 때 종료
        if cfg.get("mode", "").lower() == "once":
            remaining = &#91;]
            for hh, mm in times:
                dt = datetime.combine(today, datetime.min.time()).replace(hour=hh, minute=mm)
                if dt &gt; speak_time:
                    remaining.append(dt)
            if not remaining:
                print("&#91;alarm] once 모드 완료. 종료")
                return

        time.sleep(1)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n&#91;alarm] 사용자에 의해 종료됨")
</code></pre>



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



<h2 class="wp-block-heading">3) 실행 방법(수동 테스트)</h2>



<pre class="wp-block-code"><code>cd ~/edge_alarm
python3 alarm_tts.py
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>당장 테스트하고 싶으면 <code>times</code>를 현재 시간 기준으로 1~2분 뒤로 잠깐 바꿔보면 바로 확인됩니다.</p>
</blockquote>



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



<h2 class="wp-block-heading">4) systemd 자동 실행(부팅 시 자동 시작)</h2>



<h3 class="wp-block-heading">1) 서비스 파일 생성</h3>



<pre class="wp-block-code"><code>sudo nano /etc/systemd/system/edge-alarm.service
</code></pre>



<h3 class="wp-block-heading">2) 아래 내용 입력(경로는 본인 계정에 맞게 수정)</h3>



<pre class="wp-block-code"><code>&#91;Unit]
Description=Edge TTS Alarm (EdgeTTS cache + dedup + systemd)
After=network.target sound.target

&#91;Service]
Type=simple
WorkingDirectory=/home/orangepi/edge_alarm
ExecStart=/usr/bin/python3 /home/orangepi/edge_alarm/alarm_tts.py
Restart=always
RestartSec=3

&#91;Install]
WantedBy=multi-user.target
</code></pre>



<h3 class="wp-block-heading">3) 적용 및 실행</h3>



<pre class="wp-block-code"><code>sudo systemctl daemon-reload
sudo systemctl enable --now edge-alarm.service
sudo systemctl status edge-alarm.service
</code></pre>



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



<h2 class="wp-block-heading">운영 팁(실제로 써보면 도움이 되는 부분)</h2>



<ul class="wp-block-list">
<li><strong>네트워크가 잠깐 끊겨도</strong> 이미 만들어둔 mp3 캐시가 있으면 재생은 계속 됩니다.</li>



<li>“같은 시간에 두 번 울림”이 싫다면 <strong>상태파일(alarm_state.json)</strong> 방식이 꽤 든든합니다.</li>



<li>멘트/시간/평일여부는 코드가 아니라 <strong>설정파일 하나로</strong> 운영하면 나중에 유지보수가 편해요.</li>
</ul>



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



<h2 class="wp-block-heading">FAQ</h2>



<p><strong>Q. 공휴일 제외는 어떻게 동작해요?</strong><br>A. <code>holidays</code> 라이브러리에서 KR 공휴일을 체크해서 해당 날짜면 스킵합니다.</p>



<p><strong>Q. 스피커가 USB/블루투스면 안 나올 때가 있어요.</strong><br>A. 대부분 “기본 출력 장치”가 다르게 잡혀서 생깁니다. 먼저 Ubuntu 사운드 출력 장치를 확인해 주세요.</p>



<p><strong>Q. 멘트를 바꾸려면 코드를 수정해야 하나요?</strong><br>A. 아니요. <code>message_template</code>만 바꾸면 됩니다.</p>



<p></p>
<p>게시물 <a href="https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/">파이썬으로 음성 알람 만들기: EdgeTTS 캐시 + 중복방지 + systemd 자동실행</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ed%8c%8c%ec%9d%b4%ec%8d%ac%ec%9c%bc%eb%a1%9c-%ec%9d%8c%ec%84%b1-%ec%95%8c%eb%9e%8c-%eb%a7%8c%eb%93%a4%ea%b8%b0-edgetts-%ec%ba%90%ec%8b%9c-%ec%a4%91%eb%b3%b5%eb%b0%a9%ec%a7%80-systemd-%ec%9e%90/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>터미널에서 바로 쓰는 한국어 음성 비서 만들기: GPT + Edge TTS (실무용)</title>
		<link>https://howinfo.kr/%ed%84%b0%eb%af%b8%eb%84%90%ec%97%90%ec%84%9c-%eb%b0%94%eb%a1%9c-%ec%93%b0%eb%8a%94-%ed%95%9c%ea%b5%ad%ec%96%b4-%ec%9d%8c%ec%84%b1-%eb%b9%84%ec%84%9c-%eb%a7%8c%eb%93%a4%ea%b8%b0-gpt-edge-tts/</link>
					<comments>https://howinfo.kr/%ed%84%b0%eb%af%b8%eb%84%90%ec%97%90%ec%84%9c-%eb%b0%94%eb%a1%9c-%ec%93%b0%eb%8a%94-%ed%95%9c%ea%b5%ad%ec%96%b4-%ec%9d%8c%ec%84%b1-%eb%b9%84%ec%84%9c-%eb%a7%8c%eb%93%a4%ea%b8%b0-gpt-edge-tts/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Tue, 10 Feb 2026 11:58:53 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[AI워크플로우]]></category>
		<category><![CDATA[EdgeTTS]]></category>
		<category><![CDATA[GPT]]></category>
		<category><![CDATA[mpg123]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[ubuntu]]></category>
		<category><![CDATA[음성비서]]></category>
		<category><![CDATA[자동화]]></category>
		<category><![CDATA[터미널도구]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1565</guid>

					<description><![CDATA[<p>회의 준비하다가 갑자기 문득 이런 순간이 있어요. 그래서 저는 아주 단순한 목표로 시작했어요. 터미널에 질문을 치면 GPT가 답하고, 그 답을...</p>
<p>게시물 <a href="https://howinfo.kr/%ed%84%b0%eb%af%b8%eb%84%90%ec%97%90%ec%84%9c-%eb%b0%94%eb%a1%9c-%ec%93%b0%eb%8a%94-%ed%95%9c%ea%b5%ad%ec%96%b4-%ec%9d%8c%ec%84%b1-%eb%b9%84%ec%84%9c-%eb%a7%8c%eb%93%a4%ea%b8%b0-gpt-edge-tts/">터미널에서 바로 쓰는 한국어 음성 비서 만들기: GPT + Edge TTS (실무용)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>회의 준비하다가 갑자기 문득 이런 순간이 있어요.</p>



<ul class="wp-block-list">
<li>“이거 한 줄만 물어보면 되는데… 다시 브라우저 열기 귀찮다”</li>



<li>“답변은 길어질 것 같은데, 화면 보는 대신 그냥 <strong>읽어줬으면</strong> 좋겠다”</li>



<li>“업무 중에 손은 키보드/마우스에 묶여 있는데, 짧게 대화하듯 확인하고 싶다”</li>
</ul>



<p>그래서 저는 아주 단순한 목표로 시작했어요.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>터미널에 질문을 치면 GPT가 답하고, 그 답을 바로 한국어 음성으로 읽어주는 작은 비서</strong></p>
</blockquote>



<p>이번 글에서는 제가 실제 소스를 기준으로, 설치부터 운영 팁까지 한 번에 정리해볼게요.</p>



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



<h2 class="wp-block-heading">1) 이 스크립트가 하는 일 (한 줄 요약)</h2>



<p><strong>나&gt; 질문 입력</strong> → <strong>GPT 답변 생성</strong> → <strong>Edge TTS로 mp3 생성</strong> → <strong>mpg123로 즉시 재생</strong></p>



<p>핵심은 “대화가 끊기지 않게” 만드는 거예요.<br>기존 버전에서는 답변이 길어지면 500자에서 <strong>뚝 잘려서</strong> 읽히는 문제가 있었는데, 이 소스에서는 <strong>문장 단위로 나눠서 끝까지 읽는 방식</strong>으로 개선했습니다.</p>



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



<h2 class="wp-block-heading">2) 왜 이소스가 실무에 더 편하냐면</h2>



<p>제가 실제로 써보니, 딱 두 가지가 중요했어요.</p>



<h3 class="wp-block-heading">✅ (1) 답변이 길어져도 끝까지 읽어줌</h3>



<p>업무 질문은 생각보다 길게 답이 나오는 경우가 많아요.<br>“요약 + 근거 + 단계별 명령어” 같은 답변이 나오면 500자 제한으로 끊기는 순간 흐름이 무너집니다.</p>



<p>답변을 <strong>문장 단위로 쪼개서</strong> 자연스럽게 이어 읽어요.</p>



<h3 class="wp-block-heading">✅ (2) asyncio.run() 반복 호출 제거</h3>



<p>환경에 따라(특히 이벤트 루프가 이미 돌아가는 환경) <code>asyncio.run()</code>을 반복하면 충돌이 날 때가 있어요.<br>이 소스에서는 이벤트 루프를 한 번만 만들고 계속 재사용하는 방식이라 안정성이 좋아집니다.</p>



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



<h2 class="wp-block-heading">3) 준비물 (Ubuntu/Orange Pi 기준)</h2>



<ul class="wp-block-list">
<li>Python 3.9+ (대부분 OK)</li>



<li>패키지: <code>openai</code>, <code>edge-tts</code></li>



<li>재생기: <code>mpg123</code></li>



<li>그리고 가장 중요한 <strong>OpenAI API Key</strong></li>
</ul>



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



<h2 class="wp-block-heading">4) 설치 (실제로 이대로 하면 됩니다)</h2>



<h3 class="wp-block-heading">4-1) mpg123 설치</h3>



<pre class="wp-block-code"><code>sudo apt-get update
sudo apt-get install -y mpg123
</code></pre>



<h3 class="wp-block-heading">4-2) 파이썬 라이브러리 설치</h3>



<pre class="wp-block-code"><code>pip install -U openai edge-tts
</code></pre>



<h3 class="wp-block-heading">4-3) API 키 설정</h3>



<pre class="wp-block-code"><code>export OPENAI_API_KEY="sk-여기에_키_입력"
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>실무 팁: 매번 export 치기 귀찮으면<br><code>~/.bashrc</code> 또는 <code>~/.profile</code>에 넣어두면 편합니다.</p>
</blockquote>



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



<h2 class="wp-block-heading">5) 실행 (가장 기본 세팅)</h2>



<pre class="wp-block-code"><code>python3 text_gpt_edge_tts_v4.py
</code></pre>



<p>실행하면 터미널에 이렇게 뜹니다.</p>



<ul class="wp-block-list">
<li><code>나&gt;</code> 프롬프트가 나오고,</li>



<li>입력하면 <code>GPT&gt;</code> 답변이 출력되고,</li>



<li>이어서 바로 음성으로 재생됩니다.</li>
</ul>



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



<h2 class="wp-block-heading">6) 자주 쓰는 튜닝 포인트 (환경변수로 조절)</h2>



<p>실무에서 “한 번 만들어두고 계속 쓰는 도구”가 되려면, 튜닝이 쉬워야 해요.<br>이 스크립트는 대부분 환경변수로 바꿀 수 있게 해놨습니다.</p>



<h3 class="wp-block-heading">✅ 모델 바꾸기</h3>



<pre class="wp-block-code"><code>export GPT_MODEL="gpt-4o-mini"
</code></pre>



<h3 class="wp-block-heading">✅ 목소리 바꾸기 (한국어)</h3>



<pre class="wp-block-code"><code>export TTS_VOICE="ko-KR-SunHiNeural"
</code></pre>



<h3 class="wp-block-heading">✅ 말하기 속도/볼륨</h3>



<pre class="wp-block-code"><code>export TTS_RATE="+10%"
export TTS_VOLUME="+0%"
</code></pre>



<h3 class="wp-block-heading">✅ “한 번에 읽는 길이” 조절 (청크 크기)</h3>



<pre class="wp-block-code"><code>export MAX_SPEAK_CHARS="450"
</code></pre>



<ul class="wp-block-list">
<li>너무 자주 끊기면 값을 올리고(500~700)</li>



<li>문장 끝이 어색하게 잘리면 조금 낮추는 게(350~500) 안정적이었습니다.</li>
</ul>



<h3 class="wp-block-heading">✅ TTS 모드 선택</h3>



<pre class="wp-block-code"><code>export TTS_MODE="chunk"   # 기본: 끝까지 읽기
# export TTS_MODE="clamp" # 기존처럼 잘라 읽기
</code></pre>



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



<h2 class="wp-block-heading">7) 운영하면서 “진짜 도움이 됐던” 사용 패턴</h2>



<p>제가 실제로 써보면서 효과 있었던 사용 패턴을 몇 개 공유할게요.</p>



<h3 class="wp-block-heading">(1) 짧은 업무 확인</h3>



<ul class="wp-block-list">
<li>“이 로그 메시지 의미가 뭐야?”</li>



<li>“nginx에서 502 나올 때 점검 순서 정리해줘”</li>



<li>“이 에러는 보통 어디서 터지지?”</li>
</ul>



<p>이런 것들은 화면으로 읽기보다 <strong>음성으로 들으면</strong> 손이 자유로워서 편합니다.</p>



<h3 class="wp-block-heading">(2) 문서 초안/메일 초안 만들기</h3>



<ul class="wp-block-list">
<li>“고객에게 보낼 공지 초안 부탁해”</li>



<li>“실무적인 체크리스트 형태로 정리해줘”</li>
</ul>



<p>이럴 때 답이 길어지는데 v4는 중간에 끊기지 않아서 좋았어요.</p>



<h3 class="wp-block-heading">(3) “내가 지금 뭘 해야 하지?” 정리용</h3>



<p>업무가 복잡해질수록, 오히려 이런 질문이 유용합니다.</p>



<ul class="wp-block-list">
<li>“지금 내가 해야 할 일을 5개로 줄여줘”</li>



<li>“우선순위를 정해줘(긴급/중요 기준)”</li>
</ul>



<p>음성으로 들으면 리듬이 생겨서 실행이 빨라져요.</p>



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



<h2 class="wp-block-heading">8) 트러블슈팅 (운영하다 보면 꼭 만나는 것들)</h2>



<h3 class="wp-block-heading">✅ mpg123: command not found</h3>



<p>→ 설치 안 된 상태입니다.</p>



<pre class="wp-block-code"><code>sudo apt-get install -y mpg123
</code></pre>



<h3 class="wp-block-heading">✅ OPENAI_API_KEY 오류 / 401</h3>



<p>→ 키가 없거나 잘못된 값입니다.</p>



<pre class="wp-block-code"><code>echo $OPENAI_API_KEY
</code></pre>



<p>출력이 비어 있으면 export가 적용 안 된 거예요.</p>



<h3 class="wp-block-heading">✅ 음성은 생성되는데 소리가 안 난다</h3>



<p>이건 환경이 다양해서 원인이 여러 개인데, 경험상 체크 순서는 이렇습니다.</p>



<ol class="wp-block-list">
<li>서버/장비에서 실제 오디오 출력 장치가 맞는지</li>



<li><code>mpg123</code>가 소리를 낼 수 있는 상태인지 (권한/장치)</li>



<li>헤드리스 환경이면 기본 오디오 장치가 비정상일 수 있음</li>
</ol>



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



<h2 class="wp-block-heading">9) 실무에서 꼭 챙길 보안 포인트</h2>



<p>이런 스크립트는 “내 PC에서만 쓰는 작은 도구”처럼 보여도, 실무에서는 습관이 중요해요.</p>



<ul class="wp-block-list">
<li><strong>API 키를 코드에 하드코딩하지 않기</strong><br>→ 환경변수로 관리하는 게 기본입니다.</li>



<li>가능하면 <code>.bashrc</code>에 넣되, 공유/백업 파일에 키가 올라가지 않도록 주의</li>



<li>회사 자산/업무망 장비에서 돌릴 때는 로그/히스토리 저장 여부도 고려</li>
</ul>



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



<h2 class="wp-block-heading">10) 다음 단계 아이디어 (여기서부터가 확장 포인트)</h2>



<p>여기까지는 “키보드 입력 기반 음성 비서”였고, 다음 단계는 이쪽이 재밌습니다.</p>



<ul class="wp-block-list">
<li><strong>마이크 입력(녹음) → Whisper(STT) → GPT → TTS</strong><br>완전한 음성 비서 형태로 확장 가능</li>



<li>답변을 읽는 동안 <strong>중간에 끊기(Stop)</strong> 기능 추가</li>



<li>질문/답변을 파일로 저장해서 <strong>업무 로그처럼 쌓기</strong></li>



<li>(저는 이걸 Note Station 자동 정리 파이프라인과 연결해서 “회의 음성 → STT → 요약 → 노트 자동 삽입”까지도 확장 중입니다)</li>
</ul>



<p>파이썬 소스코드 아래 </p>



<div class="wp-block-file"><a id="wp-block-file--media-cf4ca2c0-7b4a-41dd-aaf5-5f487dff2be3" href="https://howinfo.kr/wp-content/uploads/2026/02/gpt_edge_tts.zip">gpt_edge_tts</a><a href="https://howinfo.kr/wp-content/uploads/2026/02/gpt_edge_tts.zip" class="wp-block-file__button wp-element-button" download aria-describedby="wp-block-file--media-cf4ca2c0-7b4a-41dd-aaf5-5f487dff2be3">다운로드</a></div>



<p>오렌지파이5에서 돌려봤는데 답변이 조금 늦습니다. 좀더 성능좋은 컴퓨터에서는 대화가 어느정도 되었습니다. </p>



<p>참고하세요. </p>
<p>게시물 <a href="https://howinfo.kr/%ed%84%b0%eb%af%b8%eb%84%90%ec%97%90%ec%84%9c-%eb%b0%94%eb%a1%9c-%ec%93%b0%eb%8a%94-%ed%95%9c%ea%b5%ad%ec%96%b4-%ec%9d%8c%ec%84%b1-%eb%b9%84%ec%84%9c-%eb%a7%8c%eb%93%a4%ea%b8%b0-gpt-edge-tts/">터미널에서 바로 쓰는 한국어 음성 비서 만들기: GPT + Edge TTS (실무용)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%ed%84%b0%eb%af%b8%eb%84%90%ec%97%90%ec%84%9c-%eb%b0%94%eb%a1%9c-%ec%93%b0%eb%8a%94-%ed%95%9c%ea%b5%ad%ec%96%b4-%ec%9d%8c%ec%84%b1-%eb%b9%84%ec%84%9c-%eb%a7%8c%eb%93%a4%ea%b8%b0-gpt-edge-tts/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>바이브 코딩으로 만든 Windows 바탕화면 캘린더 프로그램</title>
		<link>https://howinfo.kr/%eb%b0%94%ec%9d%b4%eb%b8%8c-%ec%bd%94%eb%94%a9%ec%9c%bc%eb%a1%9c-%eb%a7%8c%eb%93%a0-windows-%eb%b0%94%ed%83%95%ed%99%94%eb%a9%b4-%ec%ba%98%eb%a6%b0%eb%8d%94/</link>
					<comments>https://howinfo.kr/%eb%b0%94%ec%9d%b4%eb%b8%8c-%ec%bd%94%eb%94%a9%ec%9c%bc%eb%a1%9c-%eb%a7%8c%eb%93%a0-windows-%eb%b0%94%ed%83%95%ed%99%94%eb%a9%b4-%ec%ba%98%eb%a6%b0%eb%8d%94/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Mon, 09 Feb 2026 08:50:20 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[ai코딩]]></category>
		<category><![CDATA[exe만들기]]></category>
		<category><![CDATA[Pyinstaller]]></category>
		<category><![CDATA[PyQt5]]></category>
		<category><![CDATA[개인업무관리]]></category>
		<category><![CDATA[데스크톱앱개발]]></category>
		<category><![CDATA[바이브코딩]]></category>
		<category><![CDATA[바탕화면캘린더]]></category>
		<category><![CDATA[비개발자개발]]></category>
		<category><![CDATA[업무관리도구]]></category>
		<category><![CDATA[윈도우캘린더]]></category>
		<category><![CDATA[윈도우프로그램]]></category>
		<category><![CDATA[파이썬GUI]]></category>
		<category><![CDATA[파이썬실행파일]]></category>
		<category><![CDATA[파이썬프로그램]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1466</guid>

					<description><![CDATA[<p>파이썬으로 설치형 프로그램과 실행파일 까지 만드는 방법 실제 개발 기록 (전체소스 제공) “개발을 잘 몰라도, AI와 대화만으로 설치형 프로그램을 만들...</p>
<p>게시물 <a href="https://howinfo.kr/%eb%b0%94%ec%9d%b4%eb%b8%8c-%ec%bd%94%eb%94%a9%ec%9c%bc%eb%a1%9c-%eb%a7%8c%eb%93%a0-windows-%eb%b0%94%ed%83%95%ed%99%94%eb%a9%b4-%ec%ba%98%eb%a6%b0%eb%8d%94/">바이브 코딩으로 만든 Windows 바탕화면 캘린더 프로그램</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<h3 class="wp-block-heading">파이썬으로 설치형 프로그램과 실행파일 까지 만드는 방법 실제 개발 기록 (전체소스 제공)</h3>



<p>“개발을 잘 몰라도, AI와 대화만으로 설치형 프로그램을 만들 수 있을까?”</p>



<p>이번 프로젝트는 그 질문에서 시작했습니다.<br>목표는 단순하지만 분명했습니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>Windows PC 바탕화면에서 항상 보이는 달력 + 일정 관리 프로그램</strong><br>그리고 <strong>나중에 Android 앱과 연동 가능한 구조</strong></p>
</blockquote>



<p>웹앱이 아닌 <strong>완전한 설치형 프로그램</strong>으로요.</p>



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



<h2 class="wp-block-heading">📌 프로젝트 목표 정리</h2>



<p>이번 프로젝트의 핵심 목표는 아래 4가지였습니다.</p>



<ul class="wp-block-list">
<li>Windows 바탕화면에서 실행되는 <strong>독립형 캘린더 프로그램</strong></li>



<li>날짜별 일정 <strong>추가 / 수정 / 삭제</strong></li>



<li>PC 재부팅 후에도 유지되는 <strong>로컬 데이터 저장</strong></li>



<li>향후 Android 앱과 <strong>동기화 가능한 구조 설계</strong></li>
</ul>



<p>특히 중요한 조건은 하나였습니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>❗ <strong>코드는 직접 작성하지 않고, ‘바이브 코딩(자연어 + AI)’으로만 진행</strong></p>
</blockquote>



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



<h2 class="wp-block-heading">🛠 사용한 기술 스택</h2>



<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>언어</td><td>Python</td></tr><tr><td>UI</td><td>PyQt5</td></tr><tr><td>데이터</td><td>SQLite</td></tr><tr><td>실행 파일</td><td>PyInstaller</td></tr></tbody></table></figure>



<p><strong>이유는 단순합니다.</strong></p>



<ul class="wp-block-list">
<li>Python은 비개발자도 진입 장벽이 낮고</li>



<li>PyQt는 Windows 네이티브 느낌을 가장 잘 살릴 수 있으며</li>



<li>SQLite는 서버 없이도 안정적인 데이터 저장이 가능하고</li>



<li>PyInstaller로 <strong>단일 파일 배포</strong>가 가능하기 때문입니다.</li>
</ul>



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



<h2 class="wp-block-heading">🖥️ 1단계: Windows 데스크톱 캘린더 구현</h2>



<p>가장 먼저 구현한 것은 <strong>기본 달력 UI</strong>입니다.</p>



<h3 class="wp-block-heading">구현된 주요 기능</h3>



<ul class="wp-block-list">
<li>월별 달력 표시</li>



<li>오늘 날짜 강조</li>



<li>이전 / 다음 달 이동</li>



<li>날짜 클릭 시 일정 목록 표시</li>
</ul>



<p>처음에는 “그냥 달력만 나오면 되겠지”라고 생각했지만,<br>막상 써보니 <strong>업무용으로는 부족</strong>했습니다.</p>



<p>그래서 바로 다음 단계로 넘어갔습니다.</p>



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



<h2 class="wp-block-heading">📝 2단계: 일정 관리 기능 추가</h2>



<p>업무용 캘린더의 핵심은 결국 <strong>일정 관리</strong>입니다.</p>



<p>추가한 기능은 다음과 같습니다.</p>



<ul class="wp-block-list">
<li>일정 추가 / 수정 / 삭제</li>



<li>시간, 설명, 색상 지정</li>



<li>완료 여부 체크</li>



<li>날짜별 일정 자동 저장</li>
</ul>



<p>이 모든 데이터는 <strong>SQLite 파일 하나</strong>로 관리됩니다.</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>👉 프로그램 폴더에 생성되는 <code>.db</code> 파일 하나만 백업하면<br>모든 일정이 그대로 복원됩니다.</p>
</blockquote>



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



<h2 class="wp-block-heading">💾 3단계: 로컬 저장 구조 설계 (중요)</h2>



<p>이번 프로젝트에서 꽤 신경 쓴 부분입니다.</p>



<h3 class="wp-block-heading">왜 로컬 저장을 먼저 선택했을까?</h3>



<ul class="wp-block-list">
<li>회사 PC 환경에서는 외부 서버 접근이 제한되는 경우가 많고</li>



<li>개인 업무 일정은 <strong>클라우드보다 로컬이 더 안전</strong>한 경우도 많기 때문입니다.</li>
</ul>



<p>그래서 구조를 이렇게 잡았습니다.</p>



<pre class="wp-block-code"><code>&#91; UI (PyQt) ]
      ↓
&#91; Repository ]
      ↓
&#91; SQLite Database ]
</code></pre>



<p>이 구조 덕분에<br>👉 나중에 <strong>Firebase / REST API / Android 앱 연동</strong>을 붙이기도 쉽습니다.</p>



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



<h2 class="wp-block-heading">⚠️ 예상치 못한 문제: 회사 SSL / 보안 환경</h2>



<p>가장 큰 난관은 <strong>코드가 아니라 환경</strong>이었습니다.</p>



<h3 class="wp-block-heading">발생한 문제</h3>



<ul class="wp-block-list">
<li>회사 네트워크에서 PyPI SSL 인증서 오류</li>



<li>PyQt5, PyInstaller 설치 실패</li>



<li>EXE 생성 단계에서 계속 중단</li>
</ul>



<h3 class="wp-block-heading">해결 방법</h3>



<ul class="wp-block-list">
<li><code>--trusted-host</code> 옵션 사용</li>



<li>미러 서버 활용</li>



<li>오프라인 wheel 파일 설치 가이드 제공</li>
</ul>



<p>결국 <strong>회사 보안 환경에서도 설치 가능</strong>한 배치 파일 세트를 완성했습니다.</p>



<p>이 부분은 “실무 개발”에서 정말 중요한 경험이었습니다.</p>



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



<h2 class="wp-block-heading">📦 4단계: 실행 파일 생성</h2>



<p>최종 목표였던 <strong>독립 실행 파일</strong>도 완성했습니다.</p>



<h3 class="wp-block-heading">실행 파일의 장점</h3>



<ul class="wp-block-list">
<li>Python 설치 불필요</li>



<li>더블 클릭으로 실행</li>



<li>다른 PC로 복사해서 바로 사용 가능</li>
</ul>



<p>생성 명령은 단순하지만,<br>앞선 SSL 문제를 해결하지 않으면 절대 여기까지 올 수 없었습니다.</p>



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



<h2 class="wp-block-heading">📎 첨부 파일 안내</h2>



<p>이 글과 함께 아래 파일들을 <strong>블로그에 첨부</strong>할 예정입니다.</p>



<h3 class="wp-block-heading">📁 첨부 파일</h3>



<ul class="wp-block-list">
<li>Python 전체 소스 코드</li>



<li>실행용 배치 파일</li>



<li>EXE 생성용 스크립트</li>



<li>완성된 실행 파일 (<code>DesktopCalendar</code>)</li>



<li>사용 설명서 (README)</li>
</ul>



<p></p>



<p></p>



<p>👉 <strong>코드를 몰라도</strong></p>



<ul class="wp-block-list">
<li>압축 해제</li>



<li>실행</li>



<li>바로 사용 가능하도록 구성했습니다.</li>
</ul>



<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><strong>이제 “개발을 할 줄 아는 사람”과<br>“개발을 설계할 수 있는 사람”의 경계가 빠르게 무너지고 있다</strong></p>
</blockquote>



<p>코드를 직접 치지 않아도,</p>



<ul class="wp-block-list">
<li>요구사항을 명확히 말할 수 있고</li>



<li>문제를 설명할 수 있고</li>



<li>결과를 검증할 수 있다면</li>
</ul>



<p>👉 <strong>실제 제품 수준의 프로그램도 만들 수 있다</strong>는 걸 확인했습니다.</p>



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



<h2 class="wp-block-heading">🔮 다음 단계 계획</h2>



<p>다음 단계는 자연스럽게 이어집니다.</p>



<ul class="wp-block-list">
<li>Android 앱 버전 제작</li>



<li>PC ↔ 모바일 일정 동기화</li>



<li>알림 기능 추가</li>



<li>반복 일정 / 태그 기능</li>
</ul>



<p>이 역시 <strong>바이브 코딩 방식</strong>으로 계속 진행할 예정입니다.</p>



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



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



<p>이 글은 단순한 개발 튜토리얼이 아닙니다.</p>



<ul class="wp-block-list">
<li>“개발을 못 해도”</li>



<li>“회사 환경이 빡세도”</li>



<li>“시간이 많지 않아도”</li>
</ul>



<p>👉 <strong>AI와 함께라면 어디까지 가능한지</strong>를 기록한 실제 사례입니다.</p>



<p>같은 방식으로 뭔가 만들어보고 싶다면,<br>이 프로젝트 기록이 작은 참고 자료가 되었으면 합니다. </p>



<p>[전체소스] </p>



<div class="wp-block-file"><a id="wp-block-file--media-6824aa0b-b8d1-4336-8432-945c828d5cf3" href="https://howinfo.kr/wp-content/uploads/2026/02/calendar_app_COMPLETE.zip">calendar_app_COMPLETE</a><a href="https://howinfo.kr/wp-content/uploads/2026/02/calendar_app_COMPLETE.zip" class="wp-block-file__button wp-element-button" download aria-describedby="wp-block-file--media-6824aa0b-b8d1-4336-8432-945c828d5cf3">다운로드</a></div>



<p></p>



<figure class="wp-block-image size-full"><img decoding="async" width="594" height="663" src="https://howinfo.kr/wp-content/uploads/2026/02/calendar_s.png" alt="" class="wp-image-1468" srcset="https://howinfo.kr/wp-content/uploads/2026/02/calendar_s.png 594w, https://howinfo.kr/wp-content/uploads/2026/02/calendar_s-269x300.png 269w" sizes="(max-width: 594px) 100vw, 594px" /></figure>



<p></p>



<p></p>
<p>게시물 <a href="https://howinfo.kr/%eb%b0%94%ec%9d%b4%eb%b8%8c-%ec%bd%94%eb%94%a9%ec%9c%bc%eb%a1%9c-%eb%a7%8c%eb%93%a0-windows-%eb%b0%94%ed%83%95%ed%99%94%eb%a9%b4-%ec%ba%98%eb%a6%b0%eb%8d%94/">바이브 코딩으로 만든 Windows 바탕화면 캘린더 프로그램</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%eb%b0%94%ec%9d%b4%eb%b8%8c-%ec%bd%94%eb%94%a9%ec%9c%bc%eb%a1%9c-%eb%a7%8c%eb%93%a0-windows-%eb%b0%94%ed%83%95%ed%99%94%eb%a9%b4-%ec%ba%98%eb%a6%b0%eb%8d%94/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</title>
		<link>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/</link>
					<comments>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Mon, 09 Feb 2026 06:45:24 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[AI음성]]></category>
		<category><![CDATA[DIY프로젝트]]></category>
		<category><![CDATA[EdgeTTS]]></category>
		<category><![CDATA[OpenCV]]></category>
		<category><![CDATA[python]]></category>
		<category><![CDATA[RaspberryPi]]></category>
		<category><![CDATA[라즈베리파이]]></category>
		<category><![CDATA[모션감지]]></category>
		<category><![CDATA[보안카메라]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[알림시스템]]></category>
		<category><![CDATA[임베디드]]></category>
		<category><![CDATA[파이썬]]></category>
		<category><![CDATA[홈네트워크]]></category>
		<category><![CDATA[홈캠만들기]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1462</guid>

					<description><![CDATA[<p>집을 비울 때 누군가 들어오는지 궁금하신가요? 시중의 비싼 홈캠 대신, 라즈베리파이와 파이썬을 활용해 움직임을 감지하고 목소리로 경고를 날리는 스마트 감시...</p>
<p>게시물 <a href="https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/">[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>집을 비울 때 누군가 들어오는지 궁금하신가요? 시중의 비싼 홈캠 대신, 라즈베리파이와 파이썬을 활용해 <strong>움직임을 감지하고 목소리로 경고를 날리는 스마트 감시 시스템</strong>을 직접 만들어보았습니다. AI를 활용한 고품질 TTS 기능까지 더해 더욱 강력해진 &#8216;모션 가드&#8217; 제작기를 공유합니다.</p>



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



<h3 class="wp-block-heading">1. 주요 기능 및 특징: 이 프로젝트가 특별한 이유</h3>



<p>단순히 녹화만 하는 카메라가 아닙니다. 상황에 맞춰 즉각 대응하는 지능형 시스템입니다.</p>



<ul class="wp-block-list">
<li><strong>실시간 모션 감지:</strong> OpenCV를 활용해 지정된 ROI(관심 영역) 내의 움직임을 픽셀 단위로 분석하여 작은 변화도 놓치지 않습니다.</li>



<li><strong>고품질 AI 음성 안내:</strong> <code>edge-tts</code>를 연동하여 기계음이 아닌 자연스러운 한국어 목소리로 침입 경고 멘트를 송출합니다.</li>



<li><strong>오탐 방지 알고리즘:</strong> 연속 프레임 감지(Confirm Frames)와 쿨다운 타임을 적용해 조명 변화나 미세한 노이즈로 인한 오작동을 최소화했습니다.</li>



<li><strong>강력한 비프음 발생:</strong> 경고 멘트 후 강렬한 &#8216;삐삐삐&#8217; 패턴의 비프음을 재생해 청각적인 보안 효과를 극대화합니다.</li>
</ul>



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



<h3 class="wp-block-heading">2. 준비물 및 환경 설정: 시작하기 전에</h3>



<p>이 프로젝트를 실행하기 위해 라즈베리파이에 몇 가지 하드웨어와 라이브러리 설치가 필요합니다.</p>



<ul class="wp-block-list">
<li><strong>하드웨어:</strong>
<ul class="wp-block-list">
<li>Raspberry Pi (Zero W, 3, 4 등 모든 모델 가능)</li>



<li>USB 웹캠 또는 라즈베리파이 카메라 모듈</li>



<li>스피커 (3.5mm 오디오 잭 또는 USB 스피커)</li>
</ul>
</li>



<li><strong>소프트웨어 설치:</strong>Bash<code># 1. 시스템 의존성 설치 (음성 재생을 위한 mpg123, alsa-utils) sudo apt-get update &amp;&amp; sudo apt-get install -y mpg123 alsa-utils # 2. 파이썬 라이브러리 설치 (OpenCV, NumPy, Edge-TTS, Asyncio) pip install opencv-python numpy edge-tts asyncio </code><strong>💡 Tip:</strong> <code>pip</code> 명령어가 오류난다면 <code>pip3 install ...</code>을 시도해 보세요.</li>
</ul>



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



<h3 class="wp-block-heading">3. 핵심 코드 분석: 어떻게 움직임을 감지할까?</h3>



<p>코드의 핵심은 <strong>이전 프레임과 현재 프레임 간의 픽셀 차이를 계산하여 움직임을 수치화</strong>하는 것입니다.</p>



<h4 class="wp-block-heading">🔍 모션 감지 알고리즘 (<code>motion_ratio</code> 함수)</h4>



<p>Python</p>



<pre class="wp-block-code"><code>import cv2
import numpy as np
import os
import asyncio
import edge_tts
import time

# --- 환경 변수 설정 (값을 변경하여 감지 민감도를 조절할 수 있습니다) ---
MOTION_RATIO_THRESH = float(os.environ.get("MOTION_RATIO_THRESH", "0.03")) # 움직임 감지 임계값 (0.01~0.1 사이 권장)
CONFIRM_FRAMES = int(os.environ.get("CONFIRM_FRAMES", "5"))                 # 연속 감지 확인 프레임 수
ALERT_COOLDOWN_SEC = int(os.environ.get("ALERT_COOLDOWN_SEC", "30"))        # 경고 후 쿨다운 시간(초)
TTS_MP3_PATH = os.environ.get("TTS_MP3_PATH", "/tmp/alert.mp3")            # TTS 음성 파일 저장 경로
ALERT_MESSAGE = os.environ.get("ALERT_MESSAGE", "경고! 움직임이 감지되었습니다.") # 경고 음성 메시지
ROI_X, ROI_Y, ROI_W, ROI_H = map(int, os.environ.get("ROI", "0,0,0,0").split(',')) # 관심 영역 (x,y,width,height)

async def tts_save_mp3(text, mp3_path, voice="ko-KR-SunHiNeural"):
    """
    Edge-TTS를 사용하여 텍스트를 mp3 파일로 변환하여 저장합니다.
    """
    try:
        communicate = edge_tts.Communicate(text=text, voice=voice)
        await communicate.save(mp3_path)
    except Exception as e:
        print(f"TTS 생성 중 오류 발생: {e}")

def motion_ratio(prev_gray, gray):
    """
    두 회색조 이미지 간의 움직임 비율을 계산합니다.
    """
    diff = cv2.absdiff(prev_gray, gray) # 이전 프레임과 현재 프레임의 픽셀 차이 계산
    _, th = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY) # 임계값 처리 (차이가 25 이상인 픽셀만 흰색으로)
    th = cv2.medianBlur(th, 5) # 노이즈 제거를 위한 미디언 블러 적용
    changed_pixels = np.count_nonzero(th) # 변경된 픽셀 수 계산
    return changed_pixels / th.size # 전체 픽셀 대비 변경된 픽셀 비율 반환

def play_alert_sound(tts_path, beep_count=3, beep_duration=0.2):
    """
    경고 음성 메시지와 비프음을 재생합니다.
    """
    print("경고음 재생...")
    if os.path.exists(tts_path):
        os.system(f"mpg123 {tts_path}") # TTS 음성 재생
    
    # 비프음 재생
    for _ in range(beep_count):
        os.system(f"aplay -q -c 1 -t raw -f S16_LE -r 44100 /dev/zero") # 기본 비프음 (라즈비안에서 작동 확인)
        time.sleep(beep_duration)
        os.system(f"aplay -q -c 1 -t raw -f S16_LE -r 44100 /dev/zero") # 종료 비프음
        time.sleep(beep_duration)
    print("경고음 재생 완료.")

async def main():
    cap = cv2.VideoCapture(0) # 웹캠 (0번 장치) 초기화
    if not cap.isOpened():
        print("카메라를 열 수 없습니다.")
        return

    ret, frame = cap.read()
    if not ret:
        print("첫 프레임을 읽을 수 없습니다.")
        cap.release()
        return

    # ROI 설정이 유효하면 해당 영역으로 프레임을 자름
    if ROI_W &gt; 0 and ROI_H &gt; 0:
        frame = frame&#91;ROI_Y:ROI_Y+ROI_H, ROI_X:ROI_X+ROI_W]
        
    prev_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    
    motion_detected_count = 0
    last_alert_time = 0

    # TTS 파일 미리 생성
    await tts_save_mp3(ALERT_MESSAGE, TTS_MP3_PATH)

    print(f"모션 감지 시작. 임계값: {MOTION_RATIO_THRESH}, 확인 프레임: {CONFIRM_FRAMES}, 쿨다운: {ALERT_COOLDOWN_SEC}초")
    print(f"관심 영역(ROI): X={ROI_X}, Y={ROI_Y}, W={ROI_W}, H={ROI_H}")

    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                break

            display_frame = frame.copy() # 화면 표시용 원본 프레임 복사

            # ROI 설정이 유효하면 해당 영역으로 프레임을 자르고 ROI 표시
            if ROI_W &gt; 0 and ROI_H &gt; 0:
                frame_for_detection = frame&#91;ROI_Y:ROI_Y+ROI_H, ROI_X:ROI_X+ROI_W]
                cv2.rectangle(display_frame, (ROI_X, ROI_Y), (ROI_X+ROI_W, ROI_Y+ROI_H), (0, 255, 0), 2) # ROI 박스 그리기
            else:
                frame_for_detection = frame

            gray = cv2.cvtColor(frame_for_detection, cv2.COLOR_BGR2GRAY)
            
            ratio = motion_ratio(prev_gray, gray)
            
            current_time = time.time()

            if ratio &gt; MOTION_RATIO_THRESH:
                motion_detected_count += 1
                if motion_detected_count &gt;= CONFIRM_FRAMES and (current_time - last_alert_time) &gt; ALERT_COOLDOWN_SEC:
                    print(f"!!! 움직임 감지됨 (비율: {ratio:.4f}) !!!")
                    play_alert_sound(TTS_MP3_PATH)
                    last_alert_time = current_time
                    motion_detected_count = 0 # 알림 후 카운트 초기화
            else:
                motion_detected_count = 0 # 움직임이 없으면 카운트 초기화

            # 프레임에 감지 정보 표시 (선택 사항, 라즈베리파이 성능 고려하여 주석 처리 가능)
            # cv2.putText(display_frame, f"Motion Ratio: {ratio:.4f}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 0, 255), 2)
            # cv2.putText(display_frame, f"Alerts: {current_time - last_alert_time:.0f}s cooldown", (10, 70), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2)

            # cv2.imshow('Motion Guard Cam', display_frame) # 화면에 표시 (GUI 환경에서만 작동)
            
            prev_gray = gray # 현재 프레임을 다음 반복의 이전 프레임으로 저장

            if cv2.waitKey(1) &amp; 0xFF == ord('q'):
                break

    finally:
        cap.release()
        cv2.destroyAllWindows()
        print("프로그램 종료.")

if __name__ == '__main__':
    asyncio.run(main())

</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>코드 설명:</strong> 단순 픽셀 차이 외에도 <code>cv2.medianBlur</code>를 적용해 미세한 노이즈를 제거하여 오작동을 줄였습니다. 또한 <code>CONFIRM_FRAMES</code>로 여러 프레임에 걸쳐 움직임이 지속될 때만 감지하도록 설정하여 신뢰도를 높였습니다.</p>
</blockquote>



<h4 class="wp-block-heading">🗣 AI 음성 경고 (<code>edge_tts</code> 활용)</h4>



<p>구글 TTS(gTTS)보다 훨씬 자연스러운 Microsoft Edge의 TTS 엔진을 활용하여 고품질의 한국어 음성 경고를 구현했습니다. <code>asyncio</code>를 통해 비동기적으로 음성을 생성합니다.</p>



<p>Python</p>



<pre class="wp-block-code"><code>async def tts_save_mp3(text, mp3_path, voice="ko-KR-SunHiNeural"):
    """
    Edge-TTS를 사용하여 텍스트를 mp3 파일로 변환하여 저장합니다.
    """
    try:
        communicate = edge_tts.Communicate(text=text, voice=voice)
        await communicate.save(mp3_path)
    except Exception as e:
        print(f"TTS 생성 중 오류 발생: {e}")
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>코드 설명:</strong> <code>ko-KR-SunHiNeural</code>은 한국어 여성 목소리입니다. 이 외에도 다양한 목소리가 있으니 <code>edge-tts --list-voices</code> 명령어로 확인 후 변경해 볼 수 있습니다.</p>
</blockquote>



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



<h3 class="wp-block-heading">4. 실제 구동 팁: 나만의 환경에 최적화하기</h3>



<p>환경에 따라 설정을 미세하게 조정하면 훨씬 똑똑해집니다. 코드 상단의 <strong>환경 변수</strong>를 통해 조절할 수 있습니다.</p>



<ul class="wp-block-list">
<li><strong>민감도 조절 (<code>MOTION_RATIO_THRESH</code>):</strong> 기본값 <code>0.03</code>은 실내에서 적합합니다. 바람에 흔들리는 나뭇잎이 보이거나 외부 환경이라면 <code>0.05</code> ~ <code>0.1</code> 정도로 높여 오탐을 줄일 수 있습니다.</li>



<li><strong>관심 영역 지정 (<code>ROI_X, ROI_Y, ROI_W, ROI_H</code>):</strong>
<ul class="wp-block-list">
<li><code>ROI="0,0,0,0"</code> (기본값) : 전체 화면을 감지합니다.</li>



<li><code>ROI="100,50,400,300"</code> : X좌표 100, Y좌표 50에서 시작하여 가로 400, 세로 300 픽셀 영역만 감지합니다. 이 기능은 문이나 창문 쪽만 집중적으로 감시하게 설정할 수 있어 효율적입니다.</li>
</ul>
</li>



<li><strong>쿨다운 시간 (<code>ALERT_COOLDOWN_SEC</code>):</strong> 한 번 알림이 울린 후 지정된 시간 동안은 재알림을 하지 않습니다. 반복적인 알림으로 인한 소음 공해를 방지해 줍니다.</li>



<li><strong>연속 감지 프레임 (<code>CONFIRM_FRAMES</code>):</strong> 짧은 순간의 노이즈로 인한 오탐을 줄이기 위해, 움직임이 N 프레임 이상 연속될 때만 실제 움직임으로 간주합니다.</li>
</ul>



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



<h3 class="wp-block-heading">5. 마치며: 나만의 스마트 홈 시큐리티</h3>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p><strong>💡 직접 구현해보니:</strong> 처음에는 <code>cv2.medianBlur</code>나 <code>CONFIRM_FRAMES</code>를 적용하지 않아 바람에 흔들리는 커튼, 혹은 갑작스러운 조명 변화 때문에 알람이 계속 울려 고생했습니다. 하지만 이러한 &#8216;오탐 방지&#8217; 로직을 추가하니 시스템의 신뢰도가 비약적으로 향상되었습니다. 여러분도 환경에 맞춰 임계값이나 ROI를 조금씩 바꿔보며 최적의 보안 환경을 구축해 보세요!</p>
</blockquote>



<p>이 프로젝트는 간단하지만 매우 실용적인 라즈베리파이 활용 예시입니다. 여기에서 더 나아가 감지된 영상을 텔레그램으로 전송하거나, 특정 시간대에만 작동하도록 스케줄링하는 등 다양한 기능으로 확장할 수 있습니다.</p>



<p></p>



<p>전체소스 참고</p>



<p><strong>&#8220;이 코드는 별도의 유료 API 키 없이도 작동하며, 환경 변수만으로 간편하게 설정할 수 있도록 설계했습니다.&#8221;</strong></p>



<pre class="wp-block-code"><code>#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import os
import time
import asyncio
import tempfile
import subprocess

import cv2
import numpy as np
import edge_tts

# =========================
# 설정(튜닝 포인트)
# =========================
CAM_INDEX = int(os.environ.get("CAM_INDEX", "0"))     # /dev/video0
FRAME_W   = int(os.environ.get("FRAME_W", "640"))
FRAME_H   = int(os.environ.get("FRAME_H", "480"))

# 모션 감지 ROI (전체 화면이면 기본값 그대로 두세요)
# x,y,w,h
ROI = (
    int(os.environ.get("ROI_X", "0")),
    int(os.environ.get("ROI_Y", "0")),
    int(os.environ.get("ROI_W", str(FRAME_W))),
    int(os.environ.get("ROI_H", str(FRAME_H))),
)

# 모션 민감도: "변화한 픽셀 비율"
# - 너무 잘 울리면 ↑ (0.08~0.20)
# - 잘 안 울리면 ↓ (0.01~0.06)
MOTION_RATIO_THRESH = float(os.environ.get("MOTION_RATIO_THRESH", "0.03"))

# 픽셀 차이 임계치(조명 변화에 민감하면 ↑)
DIFF_THRESH = int(os.environ.get("DIFF_THRESH", "25"))

# 모션을 몇 프레임 연속 감지해야 트리거할지(오탐 줄임)
MOTION_CONFIRM_FRAMES = int(os.environ.get("MOTION_CONFIRM_FRAMES", "3"))

# 경고 후 쿨다운(연속 재생 방지)
ALERT_COOLDOWN_SEC = float(os.environ.get("ALERT_COOLDOWN_SEC", "15.0"))

# 프레임 처리 간격(부하 조절)
SLEEP_SEC = float(os.environ.get("SLEEP_SEC", "0.01"))

# TTS
TTS_VOICE  = os.environ.get("TTS_VOICE", "ko-KR-SunHiNeural")
TTS_RATE   = os.environ.get("TTS_RATE", "+0%")
TTS_VOLUME = os.environ.get("TTS_VOLUME", "+0%")

ALERT_TEXT = "여기 들어오시면 안됩니다. 허가된 주인님만 입장 가능합니다."

# 비프 패턴: "3초짜리 삐삐삐"를 3번 반복
BEEP_FREQ = int(os.environ.get("BEEP_FREQ", "1100"))     # Hz
BEEP_MS   = int(os.environ.get("BEEP_MS", "180"))        # beep 1회 길이
BEEP_GAP_MS = int(os.environ.get("BEEP_GAP_MS", "120"))  # beep 사이 간격
BEEP_CYCLE_SEC = float(os.environ.get("BEEP_CYCLE_SEC", "3.0"))  # 3초
BEEP_REPEAT = int(os.environ.get("BEEP_REPEAT", "3"))    # 3회 반복

# =========================
# 오디오 유틸
# =========================
def require_bins():
    for binname, pkg in &#91;("mpg123", "mpg123"), ("aplay", "alsa-utils")]:
        try:
            subprocess.run(&#91;binname, "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False)
        except FileNotFoundError:
            raise RuntimeError(f"{binname}가 없습니다. 설치: sudo apt-get install -y {pkg}")

def play_mp3(path: str):
    subprocess.run(&#91;"mpg123", "-q", path], check=False)

async def tts_save_mp3(text: str, mp3_path: str):
    communicate = edge_tts.Communicate(text=text, voice=TTS_VOICE, rate=TTS_RATE, volume=TTS_VOLUME)
    await communicate.save(mp3_path)

def speak(text: str):
    text = " ".join(text.split()).strip()
    if not text:
        return
    with tempfile.NamedTemporaryFile(suffix=".mp3", delete=True) as tf:
        asyncio.run(tts_save_mp3(text, tf.name))
        play_mp3(tf.name)

def gen_beep_wav(path: str, freq_hz: int, ms: int, volume: float = 0.4, sr: int = 16000):
    t = np.linspace(0, ms/1000.0, int(sr*ms/1000.0), endpoint=False)
    wave = (np.sin(2*np.pi*freq_hz*t) * volume).astype(np.float32)
    pcm_i16 = (np.clip(wave, -1.0, 1.0) * 32767).astype(np.int16)

    import wave as _wave
    with _wave.open(path, "wb") as wf:
        wf.setnchannels(1)
        wf.setsampwidth(2)
        wf.setframerate(sr)
        wf.writeframes(pcm_i16.tobytes())

def play_wav(path: str):
    subprocess.run(&#91;"aplay", "-q", path], check=False)

def beep_3sec_triple_repeat3():
    """
    3초 사이클 안에 '삐삐삐'(3회) 후 남는 시간 쉬기.
    그 3초 사이클을 3번 반복.
    """
    with tempfile.NamedTemporaryFile(suffix=".wav", delete=True) as tf:
        gen_beep_wav(tf.name, BEEP_FREQ, BEEP_MS)

        for _ in range(BEEP_REPEAT):
            start = time.time()

            # 삐삐삐
            for i in range(3):
                play_wav(tf.name)
                if i &lt; 2:
                    time.sleep(BEEP_GAP_MS / 1000.0)

            # 3초 맞추기
            elapsed = time.time() - start
            remain = max(0.0, BEEP_CYCLE_SEC - elapsed)
            time.sleep(remain)

# =========================
# 모션 감지 유틸
# =========================
def motion_ratio(prev_gray: np.ndarray, gray: np.ndarray) -&gt; float:
    diff = cv2.absdiff(prev_gray, gray)
    _, th = cv2.threshold(diff, DIFF_THRESH, 255, cv2.THRESH_BINARY)

    # 작은 노이즈 제거(조금만)
    th = cv2.medianBlur(th, 5)

    changed = np.count_nonzero(th)
    total = th.size
    return changed / max(1, total)

# =========================
# main
# =========================
def main():
    require_bins()

    cap = cv2.VideoCapture(CAM_INDEX)
    if not cap.isOpened():
        raise RuntimeError(f"카메라 열기 실패: CAM_INDEX={CAM_INDEX} (/dev/video{CAM_INDEX})")

    cap.set(cv2.CAP_PROP_FRAME_WIDTH, FRAME_W)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, FRAME_H)

    x, y, w, h = ROI

    print("=== Motion Guard (Camera) ===")
    print("CAM_INDEX:", CAM_INDEX)
    print("FRAME:", FRAME_W, "x", FRAME_H)
    print("ROI:", ROI)
    print("MOTION_RATIO_THRESH:", MOTION_RATIO_THRESH, "DIFF_THRESH:", DIFF_THRESH)
    print("CONFIRM_FRAMES:", MOTION_CONFIRM_FRAMES, "COOLDOWN:", ALERT_COOLDOWN_SEC)
    print("종료: Ctrl+C\n")

    prev_gray = None
    motion_hits = 0
    last_alert = 0.0

    try:
        while True:
            ok, frame = cap.read()
            if not ok or frame is None:
                time.sleep(0.05)
                continue

            roi = frame&#91;y:y+h, x:x+w]
            gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

            # 조명변화 완화(살짝 블러)
            gray = cv2.GaussianBlur(gray, (5, 5), 0)

            if prev_gray is None:
                prev_gray = gray
                time.sleep(SLEEP_SEC)
                continue

            ratio = motion_ratio(prev_gray, gray)
            prev_gray = gray

            if ratio &gt;= MOTION_RATIO_THRESH:
                motion_hits += 1
            else:
                motion_hits = max(0, motion_hits - 1)

            now = time.time()

            # 트리거 조건: 모션 연속 감지 + 쿨다운 지난 후
            if motion_hits &gt;= MOTION_CONFIRM_FRAMES and (now - last_alert) &gt; ALERT_COOLDOWN_SEC:
                last_alert = now
                motion_hits = 0

                print(f"&#91;ALERT] motion detected! ratio={ratio:.4f}")

                # 1) 경고 멘트
                speak(ALERT_TEXT)

                # 2) 삐삐삐(3초) x 3회
                beep_3sec_triple_repeat3()

            time.sleep(SLEEP_SEC)

    except KeyboardInterrupt:
        print("\n종료합니다.")
    finally:
        cap.release()

if __name__ == "__main__":
    main()

</code></pre>
<p>게시물 <a href="https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/">[라즈베리파이] 나만의 스마트 보안 카메라 만들기 (Python + OpenCV + Edge-TTS)</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/%eb%9d%bc%ec%a6%88%eb%b2%a0%eb%a6%ac%ed%8c%8c%ec%9d%b4-%eb%82%98%eb%a7%8c%ec%9d%98-%ec%8a%a4%eb%a7%88%ed%8a%b8-%eb%b3%b4%ec%95%88-%ec%b9%b4%eb%a9%94%eb%9d%bc-%eb%a7%8c%eb%93%a4%ea%b8%b0-python-op/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Orange Pi로 만드는 실내·실외 환경 음성 안내 시스템</title>
		<link>https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/</link>
					<comments>https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Sun, 08 Feb 2026 11:50:34 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[arduino연동]]></category>
		<category><![CDATA[Orangepi]]></category>
		<category><![CDATA[python자동화]]></category>
		<category><![CDATA[tts]]></category>
		<category><![CDATA[리눅스오디오]]></category>
		<category><![CDATA[스마트홈]]></category>
		<category><![CDATA[음성안내시스템]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1437</guid>

					<description><![CDATA[<p>Arduino + Python + TTS 자동 음성 알림 프로그램 분석 집이나 사무실에서“지금 온도 몇 도지?”,“밖이 많이 추울까?”같은 정보를 말로 알려주는...</p>
<p>게시물 <a href="https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/">Orange Pi로 만드는 실내·실외 환경 음성 안내 시스템</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<h3 class="wp-block-heading">Arduino + Python + TTS 자동 음성 알림 프로그램 분석</h3>



<p>집이나 사무실에서<br>“지금 온도 몇 도지?”,<br>“밖이 많이 추울까?”<br>같은 정보를 <strong>말로 알려주는 시스템</strong>이 있으면 꽤 유용합니다.</p>



<p>이번 글에서는<br>Orange Pi와 Arduino를 연동해<br><strong>실내·실외 온도/습도 + 날씨 정보를 주기적으로 음성으로 안내하는<br>Python 프로그램</strong>을 분석해봅니다.</p>



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



<h2 class="wp-block-heading">프로그램 개요</h2>



<p>이 프로그램은 다음과 같은 역할을 합니다.</p>



<ul class="wp-block-list">
<li>Arduino에서 <strong>실내 온·습도 값 수신</strong></li>



<li>OpenWeather API를 이용해 <strong>실외 날씨 정보 조회</strong></li>



<li>현재 시각 + 실내·실외 환경 정보를 <strong>자연스러운 한국어 문장으로 구성</strong></li>



<li>TTS(Text-to-Speech)로 음성 파일 생성</li>



<li>Orange Pi 스피커로 <strong>30분마다 자동 음성 안내</strong></li>
</ul>



<p>즉,<br>👉 <strong>완전 자동 환경 음성 알림 시스템</strong>입니다.</p>



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



<h2 class="wp-block-heading">전체 동작 흐름</h2>



<p>프로그램의 동작 구조를 간단히 정리하면 아래 순서입니다.</p>



<p>1️⃣ 시리얼 포트(<code>/dev/ttyUSB0</code>)로 Arduino 센서 값 수신<br>2️⃣ 실외 위치(김포시 기준) 날씨 API 호출<br>3️⃣ 현재 시각 포함한 안내 멘트 생성<br>4️⃣ TTS로 음성 파일 생성<br>5️⃣ ALSA(<code>aplay</code>)를 이용해 스피커 출력<br>6️⃣ 30분 대기 후 반복</p>



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



<h2 class="wp-block-heading">1. Arduino 연동 (실내 환경 수집)</h2>



<p>프로그램은 Arduino와 시리얼 통신을 통해<br><strong>실내 온도와 습도</strong>를 받아옵니다.</p>



<ul class="wp-block-list">
<li>통신 속도: <code>9600 bps</code></li>



<li>포트: <code>/dev/ttyUSB0</code></li>



<li>수신 값 예시:
<ul class="wp-block-list">
<li>온도: 22℃</li>



<li>습도: 38%</li>
</ul>
</li>
</ul>



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



<ul class="wp-block-list">
<li>센서 교체가 쉬움</li>



<li>Orange Pi 부하 최소화</li>



<li>확장성 좋음 (조도, 미세먼지 등 추가 가능)</li>
</ul>



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



<h2 class="wp-block-heading">2. 실외 날씨 정보 수집</h2>



<p>실외 정보는 <strong>OpenWeather API</strong>를 사용합니다.</p>



<p>수집 항목:</p>



<ul class="wp-block-list">
<li>현재 기온</li>



<li>습도</li>



<li>날씨 상태 (맑음, 흐림 등)</li>
</ul>



<p>프로그램 내부에서는<br>좌표 기반으로 김포시 날씨를 조회하고,<br>음성 안내 시에는 **“김포 바깥 기온”**처럼 사람이 듣기 쉬운 표현으로 변환합니다.</p>



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



<h2 class="wp-block-heading">3. 음성 멘트 생성 로직 (이 프로그램의 핵심)</h2>



<p>이 프로그램의 가장 좋은 점은<br><strong>단순한 값 나열이 아니라, 말하듯이 안내한다는 점</strong>입니다.</p>



<p>예시 멘트 구조:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>지금 시각은 20시 32분이에요.<br>김포 바깥 기온은 영하 8도, 습도는 49퍼센트 정도고 날씨는 맑아요.<br>실내는 온도 22도, 습도 38퍼센트 정도예요.<br>실내가 실외보다 약 30도 정도 더 따뜻해요.<br>갑자기 밖으로 나가실 땐 온도 차이에만 조금 주의해 주세요.</p>
</blockquote>



<p>단순 수치가 아니라<br>👉 <strong>상황에 맞는 안내 멘트</strong>가 자동으로 만들어집니다.</p>



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



<h2 class="wp-block-heading">4. TTS(Text-to-Speech) 처리 방식</h2>



<p>생성된 문장은 TTS 엔진을 통해 음성 파일로 변환됩니다.</p>



<p>특징:</p>



<ul class="wp-block-list">
<li>임시 파일 사용 (wav)</li>



<li>단일 채널(monoral) 출력</li>



<li>안정적인 샘플레이트로 변환 후 재생</li>
</ul>



<p>덕분에:</p>



<ul class="wp-block-list">
<li>음성 끊김 없음</li>



<li>SBC 환경에서도 안정적</li>



<li>다른 음성 엔진으로 교체도 쉬움</li>
</ul>



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



<h2 class="wp-block-heading">5. ALSA 기반 오디오 출력</h2>



<p>Orange Pi는 리눅스 기반이기 때문에<br>ALSA(<code>aplay</code>)를 이용해 음성을 재생합니다.</p>



<p>중요 포인트:</p>



<ul class="wp-block-list">
<li><strong>출력 가능한 오디오 장치 선택 필수</strong></li>



<li>마이크 전용 USB 오디오 사용 시 오류 발생 가능</li>



<li>실제 출력 장치(온보드 코덱)를 지정해야 정상 동작</li>
</ul>



<p>이 부분을 잘 설정하면<br>부팅 후 무인 상태에서도 안정적으로 음성 안내가 가능합니다.</p>



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



<h2 class="wp-block-heading">6. 주기적 실행 구조</h2>



<p>이 프로그램은 <strong>30분 간격</strong>으로 동작합니다.</p>



<ul class="wp-block-list">
<li>무한 루프 구조</li>



<li>음성 안내 후 sleep</li>



<li>별도 입력 없이 자동 반복</li>
</ul>



<p>그래서:</p>



<ul class="wp-block-list">
<li>시계처럼 사용 가능</li>



<li>아침/저녁 환경 체크용으로 적합</li>



<li>systemd 서비스로 등록하면 상시 운영 가능</li>
</ul>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="664" height="188" src="https://howinfo.kr/wp-content/uploads/2026/02/image-1.png" alt="" class="wp-image-1438" srcset="https://howinfo.kr/wp-content/uploads/2026/02/image-1.png 664w, https://howinfo.kr/wp-content/uploads/2026/02/image-1-300x85.png 300w" sizes="auto, (max-width: 664px) 100vw, 664px" /></figure>



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



<h2 class="wp-block-heading">활용 아이디어</h2>



<p>이 프로그램은 기본 구조가 탄탄해서 확장이 쉽습니다.</p>



<p>활용 예:</p>



<ul class="wp-block-list">
<li>❄️ 겨울철 외출 전 체감 온도 안내</li>



<li>🏠 집안 환경 자동 브리핑</li>



<li>👵 부모님 댁 음성 안내 시스템</li>



<li>🏢 사무실 환경 알림</li>



<li>🔔 특정 조건(급격한 온도 차)일 때만 안내</li>
</ul>



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



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



<p><code>indoor_outdoor_tts_plughw_adv_v1.py</code>는<br>단순한 TTS 예제가 아니라,</p>



<p>👉 <strong>센서 + API + 음성 안내를 결합한 실사용형 자동화 프로그램</strong>입니다.</p>



<p>Orange Pi, Arduino, 리눅스 환경에서<br>음성 기반 안내 시스템을 만들고 싶다면<br>아주 좋은 출발점이 되는 구조라고 볼 수 있습니다.</p>



<pre class="wp-block-code"><code>파일이 첨부가 안되어 아래 full 소스 제공한다 . 

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import asyncio
import os
import re
import time
import tempfile
import threading
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional, Tuple

import requests
import serial
import edge_tts

# =========================
# 사용자 설정
# =========================
CITY_NAME = "Gimpo"
COUNTRY_CODE = "KR"
TIMEZONE_NAME = "Asia/Seoul"

# 화면에/멘트에 쓸 도시 이름(한국어 표기)
DISPLAY_CITY_NAME = "김포"

# Arduino (실내 DHT11) 시리얼
SERIAL_PORT = "/dev/ttyUSB0"
SERIAL_BAUD = 9600

# 오디오 출력 장치: plughw 사용(자동 변환) -> aplay 에러 방지
AUDIO_DEVICE = "plughw:4,0"

# 30분마다 말하기
SPEAK_EVERY_MIN = 30
# 실내값 오래됨 처리(초)
INDOOR_STALE_SEC = 5 * 60
# 시작 직후 실내값 기다리기(초)
INDOOR_WAIT_ON_START_SEC = 6

# Edge TTS 보이스
VOICE = "ko-KR-SunHiNeural"
VOLUME = "+0%"
# 말 속도를 살짝 느리게 해서 자연스럽게
RATE = "-15%"

# =========================
# 권고 기준(원하면 숫자만 바꾸면 됨)
# =========================
# 실내 습도 기준(환기 권고)
VENTILATE_HUMIDITY_HIGH = 60   # % 이상
VERY_HUMID = 70                # % 이상이면 강하게

# 실내 건조(가습 권고)
DRY_HUMIDITY_LOW = 35          # % 이하

# 온도차 멘트 기준
TEMP_DIFF_NOTICE = 3.0         # 3도 이상 차이 나면 언급
TEMP_DIFF_STRONG = 7.0         # 7도 이상이면 강하게

# =========================
# 오디오 재생(강제 출력)
# =========================
PLAYER_CMD = f'aplay -D {AUDIO_DEVICE} -q "{{file}}"'

# =========================
# 실내(아두이노) 파서
# RAW 예: '습도: 38.70 %  |  온도: 25.90 °C'
# =========================
RE_INDOOR = re.compile(
    r"습도\s*:\s*(\d+(?:\.\d+)?)\s*%.*?온도\s*:\s*(-?\d+(?:\.\d+)?)\s*°?\s*C",
    re.IGNORECASE
)

@dataclass
class IndoorReading:
    t: float
    h: float
    ts: float  # epoch seconds

class IndoorSerialReader:
    """백그라운드에서 Arduino 시리얼을 계속 읽어 최신 실내 온습도 유지"""
    def __init__(self, port: str, baud: int):
        self.port = port
        self.baud = baud
        self._latest: Optional&#91;IndoorReading] = None
        self._lock = threading.Lock()
        self._stop = threading.Event()
        self._thread: Optional&#91;threading.Thread] = None

    def start(self) -> None:
        self._thread = threading.Thread(target=self._run, daemon=True)
        self._thread.start()

    def latest(self) -> Optional&#91;IndoorReading]:
        with self._lock:
            return self._latest

    def _set_latest(self, t: float, h: float) -> None:
        with self._lock:
            self._latest = IndoorReading(t=t, h=h, ts=time.time())

    def _run(self) -> None:
        while not self._stop.is_set():
            try:
                with serial.Serial(self.port, self.baud, timeout=1) as ser:
                    # 포트 열면 보드 리셋될 수 있어 여유
                    time.sleep(2.0)
                    try:
                        ser.reset_input_buffer()
                    except Exception:
                        pass

                    while not self._stop.is_set():
                        raw = ser.readline()
                        if not raw:
                            continue

                        line = raw.decode(errors="ignore").strip()
                        if not line:
                            continue

                        m = RE_INDOOR.search(line)
                        if m:
                            h = float(m.group(1))
                            t = float(m.group(2))
                            self._set_latest(t=t, h=h)
                        # 디버그 필요 시:
                        # else:
                        #     print("&#91;SERIAL RAW]", repr(line))

            except serial.SerialException as e:
                print(f"&#91;SERIAL] {type(e).__name__}: {e}")
                time.sleep(2.0)
            except Exception as e:
                print(f"&#91;SERIAL] Unexpected {type(e).__name__}: {e}")
                time.sleep(2.0)

# =========================
# 실외(Open-Meteo) 현재값
# =========================
def geocode_city(name: str, country: str) -> Tuple&#91;float, float, str]:
    url = "https://geocoding-api.open-meteo.com/v1/search"
    params = {"name": name, "count": 5, "language": "en", "format": "json"}
    r = requests.get(url, params=params, timeout=10)
    r.raise_for_status()
    data = r.json()
    results = data.get("results") or &#91;]

    for it in results:
        if (it.get("country_code") or "").upper() == country.upper():
            return float(it&#91;"latitude"]), float(it&#91;"longitude"]), it.get("name", name)

    if results:
        it = results&#91;0]
        return float(it&#91;"latitude"]), float(it&#91;"longitude"]), it.get("name", name)

    raise RuntimeError(f"Geocoding failed for {name}")

def weathercode_to_korean(code: Optional&#91;int]) -> str:
    mapping = {
        0: "맑음",
        1: "대체로 맑음",
        2: "부분적으로 흐림",
        3: "흐림",
        45: "안개",
        48: "착빙 안개",
        51: "이슬비(약함)",
        53: "이슬비(보통)",
        55: "이슬비(강함)",
        61: "비(약함)",
        63: "비(보통)",
        65: "비(강함)",
        71: "눈(약함)",
        73: "눈(보통)",
        75: "눈(강함)",
        80: "소나기(약함)",
        81: "소나기(보통)",
        82: "소나기(강함)",
        95: "뇌우",
    }
    return mapping.get(code, "날씨 정보") if code is not None else "날씨 정보"

def fetch_outdoor_current(lat: float, lon: float) -> Tuple&#91;Optional&#91;float], Optional&#91;float], Optional&#91;int]]:
    """Open-Meteo current: temperature_2m, relative_humidity_2m, weather_code"""
    url = "https://api.open-meteo.com/v1/forecast"
    params = {
        "latitude": lat,
        "longitude": lon,
        "current": "temperature_2m,relative_humidity_2m,weather_code",
        "timezone": TIMEZONE_NAME,
    }
    r = requests.get(url, params=params, timeout=10)
    r.raise_for_status()
    data = r.json()

    cur = data.get("current") or {}
    temp = cur.get("temperature_2m")
    hum = cur.get("relative_humidity_2m")
    wcode = cur.get("weather_code")

    try:
        temp = float(temp) if temp is not None else None
    except Exception:
        temp = None
    try:
        hum = float(hum) if hum is not None else None
    except Exception:
        hum = None
    try:
        wcode = int(wcode) if wcode is not None else None
    except Exception:
        wcode = None

    return temp, hum, wcode

# =========================
# TTS: mp3 -> wav(PCM) -> aplay
# =========================
async def speak(text: str) -> None:
    with tempfile.TemporaryDirectory() as td:
        mp3_path = os.path.join(td, "tts.mp3")
        wav_path = os.path.join(td, "tts.wav")

        communicate = edge_tts.Communicate(text, VOICE, rate=RATE, volume=VOLUME)
        await communicate.save(mp3_path)

        # 호환성 위해 WAV를 모노/48k/16bit PCM으로 변환
        cmd = f'ffmpeg -y -loglevel error -i "{mp3_path}" -ac 1 -ar 48000 -sample_fmt s16 "{wav_path}"'
        ret = os.system(cmd)
        if ret != 0:
            print("&#91;AUDIO] ffmpeg convert failed")
            return

        os.system(PLAYER_CMD.format(file=wav_path))

# =========================
# 권고 멘트
# =========================
def temp_diff_advice(indoor_t: float, outdoor_t: Optional&#91;float]) -> Optional&#91;str]:
    if outdoor_t is None:
        return None

    diff = indoor_t - outdoor_t
    adiff = abs(diff)

    if adiff &lt; TEMP_DIFF_NOTICE:
        return None

    if diff > 0:
        # 실내가 더 따뜻
        if adiff >= TEMP_DIFF_STRONG:
            return (
                f"실내가 실외보다 약 {adiff:.0f}도 정도 더 따뜻해요. "
                f"갑자기 밖으로 나가실 땐 온도 차이에만 조금 주의해 주세요."
            )
        return f"실내가 실외보다 {adiff:.0f}도 정도 더 따뜻한 편이에요."
    else:
        # 실내가 더 차가움
        if adiff >= TEMP_DIFF_STRONG:
            return (
                f"실내가 실외보다 약 {adiff:.0f}도 정도 더 서늘해요. "
                f"외부에서 들어오실 때 체감 온도 차이가 클 수 있어요."
            )
        return f"실내가 실외보다 {adiff:.0f}도 정도 더 낮은 편이에요."

def humidity_advice(indoor_h: float) -> Optional&#91;str]:
    # 습함 -> 환기 권고
    if indoor_h >= VERY_HUMID:
        return (
            "실내 습도가 꽤 높아요. 결로나 곰팡이를 예방하려면 "
            "잠깐 창문을 열고 환기해 주시면 좋겠어요."
        )
    if indoor_h >= VENTILATE_HUMIDITY_HIGH:
        return (
            "실내가 조금 습한 편이에요. 잠깐이라도 환기해 주시면 "
            "더 쾌적해질 것 같아요."
        )

    # 건조 -> 가습 권고
    if indoor_h &lt;= DRY_HUMIDITY_LOW:
        return (
            "실내가 조금 건조한 편이에요. 가습기나 물컵을 두면 "
            "도움이 될 수 있어요."
        )

    return None

# =========================
# 멘트 생성
# =========================
def build_message(outdoor_name: str,
                  out_t: Optional&#91;float],
                  out_h: Optional&#91;float],
                  out_desc: str,
                  indoor: Optional&#91;IndoorReading]) -> str:
    now = datetime.now()
    now_str = now.strftime("%H시 %M분")
    parts = &#91;f"지금 시각은 {now_str}이에요."]

    # 실외
    if out_t is None or out_h is None:
        parts.append(f"{outdoor_name} 바깥 온습도 정보를 지금은 가져오지 못했어요.")
    else:
        parts.append(
            f"{outdoor_name} 바깥 기온은 {out_t:.0f}도, 습도는 {out_h:.0f}퍼센트 정도고, "
            f"날씨는 {out_desc}이에요."
        )

    # 실내
    if indoor is None:
        parts.append("아직 실내 센서 값이 들어오지 않아서, 실내 온습도는 안내해 드리기 어렵네요.")
        return " ".join(parts)

    age = time.time() - indoor.ts
    if age > INDOOR_STALE_SEC:
        parts.append(
            "실내 온습도 값이 조금 오래되어서, 최신 값인지 확신하기가 어려워요."
        )
        return " ".join(parts)

    parts.append(
        f"실내는 온도 {indoor.t:.0f}도, 습도 {indoor.h:.0f}퍼센트 정도예요."
    )

    # 온도차 멘트
    td = temp_diff_advice(indoor.t, out_t)
    if td:
        parts.append(td)

    # 습도 멘트(환기/가습)
    ha = humidity_advice(indoor.h)
    if ha:
        parts.append(ha)

    return " ".join(parts)

# =========================
# 스케줄/대기
# =========================
def sleep_until_next_boundary(minutes_step: int) -> None:
    """00/30 분에 맞춰 다음 실행까지 sleep"""
    now = datetime.now()
    next_minute = ((now.minute // minutes_step) + 1) * minutes_step

    if next_minute >= 60:
        target = (now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1))
        target = target.replace(minute=next_minute - 60)
    else:
        target = now.replace(minute=next_minute, second=0, microsecond=0)

    wait = (target - now).total_seconds()
    time.sleep(max(1, wait))

def wait_for_indoor(reader: "IndoorSerialReader", max_wait_sec: int) -> Optional&#91;IndoorReading]:
    end = time.time() + max_wait_sec
    while time.time() &lt; end:
        v = reader.latest()
        if v is not None:
            return v
        time.sleep(0.2)
    return reader.latest()

# =========================
# 메인
# =========================
async def main() -> None:
    lat, lon, resolved_name = geocode_city(CITY_NAME, COUNTRY_CODE)
    # 실외 API는 resolved_name을 써도 되지만, 말할 때는 한국어 도시명 사용
    outdoor_name_for_speech = DISPLAY_CITY_NAME

    print(f"&#91;INFO] Outdoor location: {resolved_name} ({lat:.4f}, {lon:.4f}) -> speech name: {outdoor_name_for_speech}")
    print(f"&#91;INFO] Serial port: {SERIAL_PORT} @ {SERIAL_BAUD}")
    print(f"&#91;INFO] Audio device forced: {AUDIO_DEVICE}")
    print(f"&#91;INFO] Speak every {SPEAK_EVERY_MIN} min")

    reader = IndoorSerialReader(SERIAL_PORT, SERIAL_BAUD)
    reader.start()

    first = True
    while True:
        try:
            out_t, out_h, wcode = fetch_outdoor_current(lat, lon)
            out_desc = weathercode_to_korean(wcode)

            indoor_latest = wait_for_indoor(reader, INDOOR_WAIT_ON_START_SEC) if first else reader.latest()

            msg = build_message(outdoor_name_for_speech, out_t, out_h, out_desc, indoor_latest)
            print("&#91;SAY]", msg)
            await speak(msg)

        except Exception as e:
            print("&#91;ERR]", type(e).__name__, e)
            await speak("온습도 정보를 확인하는 중에 오류가 생겼어요.")

        if first:
            first = False
            time.sleep(SPEAK_EVERY_MIN * 60)
        else:
            sleep_until_next_boundary(SPEAK_EVERY_MIN)

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\n&#91;INFO] 종료합니다.")
</code></pre>
<p>게시물 <a href="https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/">Orange Pi로 만드는 실내·실외 환경 음성 안내 시스템</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/orange-pi%eb%a1%9c-%eb%a7%8c%eb%93%9c%eb%8a%94-%ec%8b%a4%eb%82%b4%c2%b7%ec%8b%a4%ec%99%b8-%ed%99%98%ea%b2%bd-%ec%9d%8c%ec%84%b1-%ec%95%88%eb%82%b4-%ec%8b%9c%ec%8a%a4%ed%85%9c/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Git, 처음 써보는 사람을 위한 가장 쉬운 정리</title>
		<link>https://howinfo.kr/git-%ec%b2%98%ec%9d%8c-%ec%8d%a8%eb%b3%b4%eb%8a%94-%ec%82%ac%eb%9e%8c%ec%9d%84-%ec%9c%84%ed%95%9c-%ea%b0%80%ec%9e%a5-%ec%89%ac%ec%9a%b4-%ec%a0%95%eb%a6%ac/</link>
					<comments>https://howinfo.kr/git-%ec%b2%98%ec%9d%8c-%ec%8d%a8%eb%b3%b4%eb%8a%94-%ec%82%ac%eb%9e%8c%ec%9d%84-%ec%9c%84%ed%95%9c-%ea%b0%80%ec%9e%a5-%ec%89%ac%ec%9a%b4-%ec%a0%95%eb%a6%ac/#respond</comments>
		
		<dc:creator><![CDATA[hong]]></dc:creator>
		<pubDate>Fri, 06 Feb 2026 12:45:17 +0000</pubDate>
				<category><![CDATA[개발·코딩]]></category>
		<category><![CDATA[git]]></category>
		<category><![CDATA[git 기초]]></category>
		<category><![CDATA[git 사용법]]></category>
		<category><![CDATA[git 입문]]></category>
		<category><![CDATA[github]]></category>
		<category><![CDATA[개발 기초]]></category>
		<category><![CDATA[개발자도구]]></category>
		<category><![CDATA[깃]]></category>
		<category><![CDATA[깃허브]]></category>
		<category><![CDATA[버전관리]]></category>
		<category><![CDATA[소스코드관리]]></category>
		<category><![CDATA[초보 개발자]]></category>
		<category><![CDATA[협업개발]]></category>
		<guid isPermaLink="false">https://howinfo.kr/?p=1295</guid>

					<description><![CDATA[<p>개발을 하다 보면“이 파일 언제 바꿨지?”“어제 코드로 다시 돌아가고 싶은데…”이런 순간이 꼭 옵니다. 이럴 때 도움을 주는 도구가 바로 Git입니다.Git은...</p>
<p>게시물 <a href="https://howinfo.kr/git-%ec%b2%98%ec%9d%8c-%ec%8d%a8%eb%b3%b4%eb%8a%94-%ec%82%ac%eb%9e%8c%ec%9d%84-%ec%9c%84%ed%95%9c-%ea%b0%80%ec%9e%a5-%ec%89%ac%ec%9a%b4-%ec%a0%95%eb%a6%ac/">Git, 처음 써보는 사람을 위한 가장 쉬운 정리</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></description>
										<content:encoded><![CDATA[
<p>개발을 하다 보면<br>“이 파일 언제 바꿨지?”<br>“어제 코드로 다시 돌아가고 싶은데…”<br>이런 순간이 꼭 옵니다.</p>



<p>이럴 때 도움을 주는 도구가 바로 <strong>Git</strong>입니다.<br>Git은 어렵게 말하면 <em>버전 관리 시스템</em>이지만,<br>쉽게 말하면 <strong>코드의 변경 기록을 차곡차곡 저장해 주는 도구</strong>라고 생각하면 됩니다.</p>



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



<h2 class="wp-block-heading">Git을 쓰면 뭐가 좋아질까?</h2>



<p>Git을 사용하면 이런 점들이 좋아집니다.</p>



<ul class="wp-block-list">
<li>코드가 망가져도 이전 상태로 되돌릴 수 있고</li>



<li>어떤 파일을 언제 수정했는지 한눈에 볼 수 있고</li>



<li>혼자 작업해도, 여러 명이 함께 작업해도 관리가 쉬워집니다</li>
</ul>



<p>처음에는 낯설 수 있지만,<br>기본 흐름만 익히면 생각보다 어렵지 않습니다.</p>



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



<h2 class="wp-block-heading">Git을 사용하기 전, 딱 한 번만 하는 설정</h2>



<p>Git을 처음 설치했다면<br>“이 작업을 누가 했는지”를 알 수 있도록 이름과 이메일을 설정합니다.</p>



<pre class="wp-block-code"><code>git config --global user.name "내 이름"
git config --global user.email "내이메일@example.com"
</code></pre>



<p>설정이 잘 되었는지는 아래 명령으로 확인할 수 있습니다.</p>



<pre class="wp-block-code"><code>git config --list
</code></pre>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>참고로 <code>--global</code>은<br>앞으로 만드는 모든 프로젝트에 공통으로 적용됩니다.</p>
</blockquote>



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



<h2 class="wp-block-heading">Git 저장소 만들기</h2>



<h3 class="wp-block-heading">새 프로젝트에서 시작하는 경우</h3>



<pre class="wp-block-code"><code>git init
</code></pre>



<p>이 한 줄로<br>“이 폴더를 Git으로 관리하겠다”라고 선언하는 셈입니다.</p>



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



<h3 class="wp-block-heading">이미 만들어진 프로젝트를 가져오는 경우</h3>



<pre class="wp-block-code"><code>git clone 저장소주소
</code></pre>



<p>GitHub에 있는 프로젝트를 그대로 내려받을 때 사용하는 방법입니다.</p>



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



<h2 class="wp-block-heading">Git의 기본 흐름 이해하기</h2>



<p>Git은 항상 비슷한 순서로 움직입니다.</p>



<ol class="wp-block-list">
<li>파일을 수정하고</li>



<li>변경된 파일을 모아두고</li>



<li>하나의 기록으로 저장하고</li>



<li>필요하면 원격 저장소로 올립니다</li>
</ol>



<p>이 과정을 반복한다고 생각하면 됩니다.</p>



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



<h2 class="wp-block-heading">지금 상태가 궁금할 때</h2>



<pre class="wp-block-code"><code>git status
</code></pre>



<p>이 명령어 하나만 잘 써도<br>“뭐가 바뀌었는지”, “아직 저장 안 된 게 뭔지”를 바로 알 수 있습니다.</p>



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



<h2 class="wp-block-heading">변경한 파일 저장하기</h2>



<h3 class="wp-block-heading">파일을 Git에 올릴 준비하기</h3>



<pre class="wp-block-code"><code>git add 파일이름
</code></pre>



<p>모든 변경 사항을 한 번에 추가하고 싶다면</p>



<pre class="wp-block-code"><code>git add .
</code></pre>



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



<h3 class="wp-block-heading">변경 내용을 하나의 기록으로 남기기</h3>



<pre class="wp-block-code"><code>git commit -m "로그인 기능 추가"
</code></pre>



<p>이 메시지는<br>나중에 다시 봤을 때 <strong>무슨 작업을 했는지 바로 알 수 있게</strong> 적는 게 좋습니다.</p>



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



<h2 class="wp-block-heading">GitHub 같은 원격 저장소에 올리기</h2>



<pre class="wp-block-code"><code>git push origin main
</code></pre>



<p>이제 내 컴퓨터에만 있던 코드가<br>원격 저장소에도 안전하게 저장됩니다.</p>



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



<h2 class="wp-block-heading">자주 쓰게 되는 명령어들</h2>



<p>개인적으로 가장 많이 쓰는 것들만 정리해보면 이 정도입니다.</p>



<ul class="wp-block-list">
<li><code>git status</code> : 지금 상태 확인</li>



<li><code>git add</code> : 변경 파일 모으기</li>



<li><code>git commit</code> : 기록 남기기</li>



<li><code>git pull</code> : 최신 코드 가져오기</li>



<li><code>git push</code> : 코드 올리기</li>
</ul>



<p>처음에는 이 다섯 가지만 익혀도 충분합니다.</p>



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



<h2 class="wp-block-heading">실수했을 때 대처 방법</h2>



<h3 class="wp-block-heading">방금 커밋한 걸 취소하고 싶을 때</h3>



<pre class="wp-block-code"><code>git reset HEAD~1
</code></pre>



<p>파일은 그대로 두고<br>커밋만 취소할 수 있습니다.</p>



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



<h3 class="wp-block-heading">원격 저장소 주소가 바뀐 경우</h3>



<pre class="wp-block-code"><code>git remote set-url origin 새주소
</code></pre>



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



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



<p>Git은 처음 접하면 낯설고 어렵게 느껴지지만,<br>실제로는 <strong>코드를 안전하게 지키기 위한 도구</strong>에 가깝습니다.</p>



<p>처음부터 모든 기능을 알 필요는 없고,</p>



<ul class="wp-block-list">
<li>상태 확인</li>



<li>저장</li>



<li>올리기</li>
</ul>



<p>이 세 가지만 자연스럽게 익혀도<br>개발이 훨씬 편해집니다.</p>



<p></p>
<p>게시물 <a href="https://howinfo.kr/git-%ec%b2%98%ec%9d%8c-%ec%8d%a8%eb%b3%b4%eb%8a%94-%ec%82%ac%eb%9e%8c%ec%9d%84-%ec%9c%84%ed%95%9c-%ea%b0%80%ec%9e%a5-%ec%89%ac%ec%9a%b4-%ec%a0%95%eb%a6%ac/">Git, 처음 써보는 사람을 위한 가장 쉬운 정리</a>이 <a href="https://howinfo.kr">하우인포-IT·테크</a>에 처음 등장했습니다.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://howinfo.kr/git-%ec%b2%98%ec%9d%8c-%ec%8d%a8%eb%b3%b4%eb%8a%94-%ec%82%ac%eb%9e%8c%ec%9d%84-%ec%9c%84%ed%95%9c-%ea%b0%80%ec%9e%a5-%ec%89%ac%ec%9a%b4-%ec%a0%95%eb%a6%ac/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>
	</channel>
</rss>
