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

<channel>
	<title>손절 보관 - 하우인포-IT·테크</title>
	<atom:link href="https://howinfo.kr/tag/%ec%86%90%ec%a0%88/feed/" rel="self" type="application/rss+xml" />
	<link>https://howinfo.kr/tag/손절/</link>
	<description>IT·AI 자동화 &#38; 인프라 전문 블로그 (하우인포)</description>
	<lastBuildDate>Wed, 25 Feb 2026 13:12:24 +0000</lastBuildDate>
	<language>ko-KR</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.1</generator>

<image>
	<url>https://howinfo.kr/wp-content/uploads/2026/02/cropped-ChatGPT-Image-2026년-2월-12일-오후-05_39_40-32x32.png</url>
	<title>손절 보관 - 하우인포-IT·테크</title>
	<link>https://howinfo.kr/tag/손절/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>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>
	</channel>
</rss>
