Compare commits
101 Commits
a61f1f67c5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 657969d115 | |||
| 4f63ec56dc | |||
| 114a1ef816 | |||
| 405a0a3228 | |||
| 86e38478dd | |||
| 5fb39272f5 | |||
| 708961b449 | |||
| 5384b1d670 | |||
| 5e47a15547 | |||
| b404af9020 | |||
| 74dcab1ba6 | |||
| d8a778cf67 | |||
| 1c8af0b523 | |||
| 9a10822533 | |||
| 29ca0d7ac1 | |||
| 0157b24a55 | |||
| 408a3e0d4a | |||
| 8e12b4027c | |||
| e3c5a92931 | |||
| 75f70bc6ed | |||
| 07d750807c | |||
| 59b85ac77b | |||
| 34498c554e | |||
| b4f510d1ad | |||
| 92d0d96368 | |||
| 3e62670b24 | |||
| db1639596b | |||
| d786f75487 | |||
| b610d01410 | |||
| f17da6d62a | |||
| 48889f0403 | |||
| 21f9e50d8f | |||
| 19b6797e47 | |||
| ebe348c766 | |||
| 4639a2d298 | |||
| 9e5b1c211e | |||
| 5eb280201d | |||
| 992b85005d | |||
| 600be2ee96 | |||
| 8eed94c044 | |||
| ea6000d343 | |||
| 815b8cc6c4 | |||
| 4321b38e30 | |||
| ad5ed12f83 | |||
| d48cba69ec | |||
| dc261263e3 | |||
| cd3f60378b | |||
| 57aeb29227 | |||
| 6428846506 | |||
| c93d1ac385 | |||
| 49a0651f05 | |||
| d5da99ed70 | |||
| 6309d760f0 | |||
| e30a0b74e1 | |||
| f38a3878b1 | |||
| 307f7844c4 | |||
| c03b3f2659 | |||
| 7e7d759b34 | |||
| 153ef20ae5 | |||
| 5009ed19b7 | |||
| 30be97ab2f | |||
| d7042430c3 | |||
| a5f6b0ecd8 | |||
| c5dd2f98a8 | |||
| f3a5858597 | |||
| 748e9bb482 | |||
| 1f0a2c8971 | |||
| a13133aabe | |||
| 5410009bea | |||
| 8c23849952 | |||
| 2cf5b38da1 | |||
| 1d6fe0f70d | |||
| bf55377fdc | |||
| 9948122ed3 | |||
| 66d372b50e | |||
| ed73ed8b18 | |||
| 3959401a65 | |||
| 76e2359216 | |||
| 434b379c13 | |||
| 212ed517ee | |||
| 85674763a3 | |||
| 008ce8be65 | |||
| dd1ad7d964 | |||
| 300a4c9ca9 | |||
| 213c0e0797 | |||
| 565689df25 | |||
| 1bbb61520c | |||
| 17ad101eb1 | |||
| 790effdf0e | |||
| 6c89c54fe5 | |||
| 03989e9df4 | |||
| c15c3a1fec | |||
| 21f4f07cbb | |||
| cee32e6dfb | |||
| 40cc290035 | |||
| 5a11a21562 | |||
| dc537f9925 | |||
| 70e2251995 | |||
| b3696949f5 | |||
| eaa276b26d | |||
| 70f6b9a34d |
@@ -21,6 +21,8 @@ node_modules/
|
||||
exec-approvals.json
|
||||
update-check.json
|
||||
.claude/
|
||||
# 설정 저널 서명키 (2026.9.3 신규) — 32B 바이너리, 권한 600
|
||||
config-journal-fingerprint.key
|
||||
|
||||
# 에이전트별 auth / 런타임 (auth-profiles, codex-home, 세션, inbox 메시지)
|
||||
/agents/*/agent/
|
||||
@@ -60,6 +62,13 @@ update-check.json
|
||||
/subagents/
|
||||
/.playwright-mcp/
|
||||
|
||||
# ----- 2026.9.3 업그레이드 산물 (런타임·마이그레이션 잔재) -----
|
||||
# doctor 가 남기는 `<원본>.migrated.<hash>.<uuid>` 백업. 롤백용이라 파일은 두되 추적은 안 한다
|
||||
*.migrated.*
|
||||
/agent/
|
||||
/session-sqlite-migration-runs/
|
||||
**/session-sqlite-import-archive/
|
||||
|
||||
# 플러그인 · 플로우 · 태스크 런타임
|
||||
/flows/
|
||||
/tasks/
|
||||
|
||||
+30
-20
@@ -11,44 +11,53 @@ OpenClaw 에이전트 간 파일 기반 통신의 topic 카탈로그. envelope
|
||||
### `securities_balance`
|
||||
|
||||
- **방향:** `stock` → `budget`
|
||||
- **목적:** 본인 증권 계좌별 잔액(평가액·예수금·총자산)을 골디에게 전달, 월간 결산 입력으로 사용
|
||||
- **목적:** 본인+가희 증권 계좌별 잔액(평가액·예수금·총자산)을 소유자별로 골디에게 전달, 월간 결산 입력으로 사용
|
||||
- **트리거:** macOS launchd `ai.openclaw.stock.send-balance` — 매월 1일 04:30 KST. 골디 결산 cron(05:00) 30분 전. (`inbox_handler.py`의 `as_of` 가드는 1·10·20일을 허용 — 과거 운영 호환 안전망)
|
||||
- **schema_version:** 1
|
||||
- **payload 스키마:**
|
||||
- **schema_version:** 2 (v1 하위호환 유지 — 수신자 `SUPPORTED_SCHEMA={1,2}`)
|
||||
- **payload 스키마 (v2):**
|
||||
|
||||
```json
|
||||
{
|
||||
"as_of": "2026-05-01",
|
||||
"owner_scope": "self_only",
|
||||
"as_of": "2026-07-01",
|
||||
"owner_scope": "self_and_gahee",
|
||||
"accounts": [
|
||||
{
|
||||
"label": "일반",
|
||||
"owner": "self",
|
||||
"account_no": "",
|
||||
"deposit": 118743,
|
||||
"eval_amount": 63177225,
|
||||
"total": 63295968,
|
||||
"position_count": 4
|
||||
"deposit": 56032,
|
||||
"eval_amount": 114294705,
|
||||
"total": 114350737,
|
||||
"position_count": 7
|
||||
},
|
||||
{ "label": "ISA", ... }
|
||||
{ "label": "ISA", "owner": "self", ... },
|
||||
{ "label": "가희_일반", "owner": "gahee", ... },
|
||||
{ "label": "가희_ISA", "owner": "gahee", ... }
|
||||
],
|
||||
"by_owner": {
|
||||
"self": { "deposit": 146362, "eval_amount": 188964561, "total": 189110923 },
|
||||
"gahee": { "deposit": 138469, "eval_amount": 22587331, "total": 22725800 }
|
||||
},
|
||||
"totals": {
|
||||
"deposit": 843705,
|
||||
"eval_amount": 114519255,
|
||||
"total": 115362960
|
||||
"deposit": 284831,
|
||||
"eval_amount": 211551892,
|
||||
"total": 211836723
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- **owner_scope:** 현재 `self_only`만 발행 (가희 계좌 제외 — `가희_` prefix 라벨 자동 필터). 향후 가희 포함 버전 필요해지면 `with_gahee` 등 새 값 도입
|
||||
- **owner_scope:** v2는 `self_and_gahee` — 본인·가희 모두 발행. `accounts[].owner` 는 `self`|`gahee` (`가희_` prefix 라벨이 gahee). 소유자 집계는 `by_owner`. `totals` 는 참고용 grand total. (v1은 `self_only`·`by_owner` 없음, 단일 소유자로 처리)
|
||||
- **수신자(골디) 처리 동작:**
|
||||
- 월간결산 cron 진입부에서 `inbox_handler.process_inbox()` 호출 (fetch_balance 이전 — 분개가 후잉 잔액에 반영되어 결산이 분개 후 스냅샷을 보도록)
|
||||
- payload의 `totals.total` 과 후잉 자산 `증권(효원)` 의 차액을 계산:
|
||||
- 차액 > 0 → 차변 `증권(효원)` / 대변 `주식평가수익` 자동 분개
|
||||
- 차액 < 0 → 차변 `주식평가손실` / 대변 `증권(효원)` 자동 분개
|
||||
- `by_owner` 의 각 소유자 total 과 후잉 자산(`self`→`증권(효원)`, `gahee`→`증권(가희)`)의 차액을 소유자별로 reconcile:
|
||||
- 차액 > 0 → 차변 `증권(효원|가희)` / 대변 `주식평가수익` 자동 분개 (손익 계정은 소유자 공용)
|
||||
- 차액 < 0 → 차변 `주식평가손실` / 대변 `증권(효원|가희)` 자동 분개
|
||||
- `|차액| < 1만원` → 노이즈로 간주, 분개 skip (processed 처리)
|
||||
- `|차액| > 1억원` → 안전 가드 발동, 분개 거부 + 텔레그램 alert + `failed/` 이동
|
||||
- 처리 후 `processed/`로 이동, `state/inbox_state.json` 의 `processed[]` 에 message_id 누적 (idempotency, 최근 1000개)
|
||||
- 결산 메일 본문에 `## 인박스 reconcile` 섹션, 텔레그램에 한 줄 요약 추가
|
||||
- 후잉에 해당 자산 항목 없으면 `skipped` (분개 안 함)
|
||||
- 소유자 하나라도 rejected/journal_failed 면 `failed/` 이동·미처리. 재실행 시 이미 분개된 소유자는 fresh 조회로 차액≈0 aligned → 중복분개 없음 (reconcile는 목표값으로 맞추기라 자연 idempotent)
|
||||
- 정상 처리 후 `processed/`로 이동, `state/inbox_state.json` 의 `processed[]` 에 message_id 누적 (idempotency, 최근 1000개)
|
||||
- 결산 메일 본문에 `## 인박스` reconcile 섹션(소유자별 라인), 텔레그램에 한 줄 요약 추가. 메일 "자산 계정별 변동"은 후잉 자산 전수 순회라 `증권(가희)`도 자동 표시
|
||||
- **GC / 적체 정책 (월간결산 cron 진입부에서 매월 자동 수행):**
|
||||
- `processed/` 의 mtime 30일 초과 envelope 자동 삭제 (`gc_processed`)
|
||||
- `failed/` 적체가 5건 이상이면 결산 메일·텔레그램에 ⚠️ alert 한 줄 추가 — 사람이 검토 후 수동 삭제 (자동 삭제 안 함, CLAUDE.md 원칙 준수)
|
||||
@@ -57,8 +66,9 @@ OpenClaw 에이전트 간 파일 기반 통신의 topic 카탈로그. envelope
|
||||
- envelope 키 누락 / `to != budget` / 미등록 topic / 미지원 `schema_version`
|
||||
- payload 키 누락 (`as_of`, `accounts`, `totals`, `owner_scope`)
|
||||
- `as_of` 가 매월 1·10·20일이 아님
|
||||
- `totals.{deposit,eval_amount,total}` 음수 또는 100억 초과
|
||||
- `totals`/`by_owner.*` 의 `{deposit,eval_amount,total}` 음수 또는 100억 초과
|
||||
- `accounts[].total` 합계가 `totals.total` 과 불일치
|
||||
- `by_owner` 에 미등록 소유자 (self/gahee 외) 또는 구조 오류
|
||||
- 차액이 1억원 초과 (분개 거부)
|
||||
- 후잉 webhook 분개 실패
|
||||
- **관련 스크립트:**
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# 월간 점검 체크리스트 (코디)
|
||||
|
||||
매월 1일 헬스체크가 텔레그램으로 알린다. 관리자님이 코디 세션을 열고 "월간 점검 해줘"라고 하면 이 문서를 따른다.
|
||||
|
||||
**여기 있는 건 전부 "스크립트로는 못 잡는 것"이다.** 주기 잡이 죽었는지·결과가 stale 한지는 `scripts/health_check.py` 가 매일 본다(대상은 `TASKS.md` §4). 이 문서는 **바깥이 변해서 우리 쪽이 조용히 어긋나는** 종류만 다룬다 — 2026-09-10 점검에서 하드 실패는 0건인데 아래 유형이 4건 나왔다.
|
||||
|
||||
**원칙:** 점검은 조회만 한다. 고칠 게 나오면 관리자님께 보고하고 승인받은 것만 손댄다. 업그레이드·트리거 변경은 특히.
|
||||
|
||||
---
|
||||
|
||||
## 1. 모델 카탈로그 — deprecated·신규·단가
|
||||
|
||||
```bash
|
||||
# 세 에이전트 라우팅 현황
|
||||
for a in main stock budget; do echo "== $a"; ~/.local/bin/openclaw models list --agent $a | grep -E "default|fallback"; done
|
||||
|
||||
# 카탈로그의 status/replacedBy/cost (본체 업그레이드 때마다 바뀐다)
|
||||
python3 -c "
|
||||
import json
|
||||
d=json.load(open('/opt/homebrew/lib/node_modules/openclaw/dist/extensions/openai/openclaw.plugin.json'))
|
||||
for m in d['modelCatalog']['providers']['openai']['models']:
|
||||
c=m.get('cost',{})
|
||||
print(f\"{m['id']:22} {m.get('status','-'):11} →{m.get('replacedBy','-'):16} in {c.get('input','?')} / out {c.get('output','?')}\")"
|
||||
```
|
||||
|
||||
- **deprecated 인데 쓰고 있는 모델**이 있는지가 핵심. 2026-09-10 에 `gpt-5.5`(primary)·`gpt-5.5-pro`(fallback#3) 둘 다 deprecated 인 걸 뒤늦게 발견해 정리했다
|
||||
- ⚠️ **폴백 체인 전체가 과금 0 이어야 한다**(관리자님 요구) — 현재 `sol → terra → luna → openrouter/free`. 새 모델을 폴백·allow 에 넣을 땐 **구독(codex) 경로인지 실측**한다: `openclaw agent --model <id> -m ping` 후 trajectory 의 `modelApi` 가 `openai-chatgpt-responses` 면 구독이다. 카탈로그의 정적 제외 규칙만 보고 판단하면 틀린다(`gpt-5.4` 는 카탈로그에 있는데 은퇴했고, terra·luna 는 제외 규칙에 걸리는데 실제로는 된다)
|
||||
- `modelPolicy.allow` 에 유료 모델이 없는지 확인한다 — 폴백 순서만으로는 수동 지정 과금을 못 막는다
|
||||
- 신규 모델이 보이면 단가·codex 카탈로그 포함 여부를 먼저 본다. `gpt-6-astra` 를 왜 안 쓰기로 했는지는 `CLAUDE.md` **Key Configuration** 참조(다시 검토하기 전에 그 세 근거부터 확인)
|
||||
|
||||
## 2. OpenClaw 본체 버전
|
||||
|
||||
```bash
|
||||
~/.local/bin/openclaw --version && npm view openclaw version # 현재 vs 최신
|
||||
```
|
||||
|
||||
- **올릴지 말지는 관리자님 결정.** 올린다면 `CLAUDE.md` **Runtime** 의 함정 3종(node 24 필수 / npm 11 의 `--allow-scripts` / doctor 가 워크스페이스·cron 을 손댐)을 먼저 읽는다
|
||||
- ⚠️ **업그레이드 후엔 "무엇이 조용히 죽었는지" 반드시 확인한다.** 2026.9.3 이 trajectory 를 파일에서 sqlite 로 옮겨 `codex_fallback_monitor` 가 예외 없이 '폴백 없음'만 반환하며 무력화됐다. 파일 경로·테이블·스키마에 의존하는 코드는 전부 재검증 대상
|
||||
|
||||
## 3. 외부 데이터 소스 생존
|
||||
|
||||
우리가 긁는 곳은 예고 없이 막힌다. **각 소스의 CLI 를 한 번씩 돌려 본다.**
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/agents/stock/workspace
|
||||
python3 scripts/holiday_sync.py --show | head -5 # nager (investing 은 이미 403)
|
||||
python3 scripts/fomc_calendar_sync.py --list | head -5 # federalreserve.gov
|
||||
python3 scripts/fnguide_client.py 005930 | head -5 # FnGuide
|
||||
python3 scripts/wisereport_client.py 005930 | head -5 # WISEreport
|
||||
python3 ~/.openclaw/workspace/scripts/youtube_briefing_digest.py latest us
|
||||
python3 ~/.openclaw/workspace/scripts/youtube_briefing_digest.py latest behive
|
||||
```
|
||||
|
||||
- ⚠️ **"실패해도 조용한" 소스가 위험하다.** `holiday_sync` 는 investing 403 이후 2개월간 stale 이었는데, 폴백(nager)이 있는데도 "investing 0건이면 저장 자체를 건너뛰는" 가드 때문에 아무 일도 안 했다. 주 소스가 죽었을 때 **폴백이 단독으로 설 수 있는지**를 본다
|
||||
- 유튜브는 스크랩 구조(`lockupViewModel`)가 바뀌면 조용히 빈 결과가 된다 → 브리핑에서 시황 카드만 사라진다
|
||||
|
||||
## 4. 문서 드리프트 — TASKS.md·CLAUDE.md vs 실제
|
||||
|
||||
```bash
|
||||
# plist 실제 스케줄 전수
|
||||
cd ~/Library/LaunchAgents && for p in ai.openclaw.*.plist; do
|
||||
echo "== $(basename $p .plist)"; plutil -p "$p" | grep -A6 StartCalendarInterval | head -8; done
|
||||
|
||||
~/.local/bin/openclaw cron list # cron 은 sqlite(state/openclaw.sqlite)
|
||||
```
|
||||
|
||||
- `TASKS.md` §1·§2 의 트리거 표기와 대조. 2026-09-10 에 IPO 요일·시뮬 주기·자본금·sim-web 탭 수·cron ID 등 **9건이 어긋나** 있었다
|
||||
- `TASKS.md` §4(헬스체크 표)의 **증거 파일이 여전히 "돌면 반드시 갱신되는" 것인지** 확인. 스크립트가 바뀌면 이 전제가 깨진다
|
||||
- 문서에 없는 launchd 잡이 있는지도 본다 (2026-09-10 `nxt-probe-oneshot` 고아 발견 → 정리)
|
||||
- ⚠️ **health-check 자신이 돌고 있는지 확인한다** — 감시자가 죽으면 알림도 같이 죽어서 아무 신호가 없다. `ls -la ~/.openclaw/logs/health-check.log` 의 mtime 이 어제 이후인지 본다
|
||||
|
||||
## 5. 자격증명·토큰 만료
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/agents/stock/workspace && python3 scripts/kiwoom_client.py token # 키움
|
||||
ls -la ~/.openclaw/credentials/
|
||||
```
|
||||
|
||||
- 키움 REST 토큰, 텔레그램 봇(동결 이력 있음 — `getMyName` 까지 확인), 후잉 API, gog(Gmail/Calendar) 인증
|
||||
- ⚠️ gog 는 **코디 셸에서 인증이 안 된다**(키체인 ACL). 검증은 `--list` 류 인증 불필요 CLI 로 하거나 launchd oneshot 경유
|
||||
|
||||
## 6. 코디 inbox
|
||||
|
||||
```bash
|
||||
ls ~/.openclaw/agents/cody/inbox/incoming/ | wc -l
|
||||
find ~/.openclaw/agents/cody/inbox/processed -mtime +7 | head # GC 대상
|
||||
```
|
||||
|
||||
`CLAUDE.md` **Cody Inbox** 의 처리 흐름을 따른다. `processed/` 7일 초과분은 이때 정리한다.
|
||||
|
||||
## 7. 디스크·백업
|
||||
|
||||
```bash
|
||||
du -sh ~/.openclaw/logs ~/.openclaw/state ~/.openclaw/agents/*/workspace/state
|
||||
cd ~/.openclaw && git log -1 --format='%cd %s' --date=format:'%m-%d %H:%M'
|
||||
```
|
||||
|
||||
- 로그 총량은 헬스체크가 매일 보지만(상한 200MB), **어느 파일이 커지고 있는지**는 사람이 봐야 판단이 된다
|
||||
- git autopush 가 매일 02:00 도는지, `.gitignore` 로 빠진 시크릿·sqlite 의 별도 백업이 있는지
|
||||
|
||||
---
|
||||
|
||||
## 점검 후
|
||||
|
||||
`agents/cody/inbox/processed/` 에 리포트를 남기는 대신, **고친 게 있으면 `CLAUDE.md`·`TASKS.md` 에 그 사실과 근거를 적는다.** 다음 점검 때 같은 걸 다시 파헤치지 않도록 "왜 그렇게 뒀는지"를 남기는 게 이 워크스페이스의 방식이다.
|
||||
@@ -13,35 +13,45 @@
|
||||
|---|---|---|
|
||||
| **OpenClaw Gateway** | `openclaw gateway --port 18789` | launchd `ai.openclaw.gateway` (상시) |
|
||||
| **비하이브 웹뷰** | `python3 ~/.openclaw/agents/stock/workspace/scripts/behive_web.py serve` | launchd `ai.openclaw.stock.behive-web` (상시) |
|
||||
| **웹 채팅 (클로·레이·골디)** | `python3 ~/.openclaw/workspace/scripts/chat_web.py serve` | launchd `ai.openclaw.chat-web` (상시, 포트 18793). `chat.hyowons.net` → NAS 리버스 프록시. 로그인 = iMessage OTP 단독(비밀번호 없음, 코드 발송 1분 1회·1시간 5회 제한) |
|
||||
| **후잉 동기화** | `python3 ~/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py` | launchd `ai.openclaw.budget.whooing-sync` (매 15분). 매 사이클 끝에 **가희 잔액 리마인더·자동분개** 모듈(`gahee_reminder.run`)도 함께 호출 — 매월 25일 10:00 KST 이후 첫 사이클에서 가희님께 iMessage 1회 발신, 이후 답신 폴링 → 텍스트면 후잉 가희주머니 차액 자동 분개, 이미지면 텔레그램 알림만 |
|
||||
| **주식 브리핑 메일** | `python3 ~/.openclaw/agents/stock/workspace/scripts/stock_portfolio_report.py send` | launchd `ai.openclaw.stock.briefing` (평일 20:10) |
|
||||
| **주식 포트폴리오 스냅샷** | `python3 ~/.openclaw/agents/stock/workspace/scripts/stock_portfolio_report.py send --no-mail` | launchd `ai.openclaw.stock.briefing` (평일 20:10) + 폴백 `-fallback-2030`(20:30, 스냅샷 없을 때만) · `-fallback-2100`(21:00, **무조건 fresh 재조회**). **메일 발송 중지**(2026-08-12 관리자님 결정 — 메일 통수 축소). `--no-mail`은 휴장일 self-skip·실패 텔레그램 알림은 유지하고 메일만 건너뜀. 목적은 자산웹 '당일 평가손익' 기준값이 되는 스냅샷 확보. 리포트 본문을 눈으로 보려면 `run`(메일 없이 평문+`/tmp/stock_report_preview.html`) |
|
||||
| **증권잔액 → 골디 inbox** | `python3 ~/.openclaw/agents/stock/workspace/scripts/send_balance_to_budget.py` | launchd `ai.openclaw.stock.send-balance` (매월 1일 04:30) |
|
||||
| **골디 inbox 처리** (단독) | `python3 ~/.openclaw/agents/budget/workspace/skills/monthly-settlement/scripts/inbox_handler.py [--dry-run]` | 트리거 없음 — 월간결산 cron 진입부에서 자동 호출. 수동 점검·재처리용 |
|
||||
| **워치리스트 모니터링** | `python3 ~/.openclaw/agents/stock/workspace/scripts/watchlist_monitor.py check` | launchd `ai.openclaw.stock.watchlist-monitor` (평일 09–15시 매 15분) |
|
||||
| **공모주 캘린더 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/ipo_calendar_sync.py` | launchd `ai.openclaw.stock.ipo-calendar-sync` (매월 1일 09:00) |
|
||||
| **워치리스트 모니터링** | `python3 ~/.openclaw/agents/stock/workspace/scripts/watchlist_monitor.py check` | launchd `ai.openclaw.stock.watchlist-monitor` (평일 **10/12/14시 3회** — 2026-05-12 15분 간격에서 축소) |
|
||||
| **트레일링 스톱 감시** | `python3 ~/.openclaw/agents/stock/workspace/scripts/trailing_monitor.py {check [--repeat N --gap S] [--force] [--dry-run]\|list}` | launchd `ai.openclaw.stock.trailing-monitor` (평일 **08:00–19:59 매 1분**, 720엔트리 — 2026-08-25 plist 실측 정정. 스크립트가 장외·휴장 self-skip). 하는 일 둘: ①자산웹에서 PIN 승인된 트레일링 예약의 조건단가를 고점 갱신 시 kt10002로 **상향만** 정정 ②**예약 트레일링(TRAILING_ARM) 발동·만료** — 발동가 도달 시 스톱주문 신규 발주 + 예약 승격, 미발동으로 등록일이 지난 대기 예약은 자동 취소(둘 다 2026-08-25 추가, 관리자님 승인). ⚠️ "신규 발주 없음"은 더 이상 사실이 아니다. 예약 0건이면 즉시 종료(API 콜 0). 상태 `state/trailing_stops.json`. ⚠️ cadence 변경 이력 2분→30초→**1분**(2026-07-30 관리자님) — 30초는 launchd로 불가해 `--repeat 2 --gap 30`(프로세스 내부 2회)으로 구현했었고, 옵션은 살아있으니 다시 쓸 땐 plist 인자만 추가 |
|
||||
| **급등락 알림** | `python3 ~/.openclaw/agents/stock/workspace/scripts/surge_monitor.py {check [--force]\|dry-run\|list}` | launchd `ai.openclaw.stock.surge-monitor` (09:00–19:59 매 1분, 660엔트리 — 스크립트가 휴장·장외 self-skip. 2026-09-22 KRX 애프터마켓 대응으로 15:35 → 20:00 확대). 토글 켠 종목만 감시(상·하한가 / VI 발동 / 자기이력 분위수) → 레이 텔레그램. 토글 0건이면 API 콜 0 |
|
||||
| **codex 폴백 감시** | `python3 ~/.openclaw/workspace/scripts/codex_fallback_monitor.py [--dry-run]` | launchd `ai.openclaw.codex-fallback-monitor` (매 5분). 주 모델이 죽어 유료 OpenRouter 로 폴백하면 클로 텔레그램. 무비용(on-disk sqlite 스캔) |
|
||||
| **git 자동 백업** | `sh ~/.openclaw/scripts/git_autopush.sh` | launchd `ai.openclaw.git-autopush` (매일 02:00). 변경 있을 때만 커밋·푸시 |
|
||||
| **헬스체크** | `python3 ~/.openclaw/scripts/health_check.py [--dry-run] [--verbose]` | launchd `ai.openclaw.health-check` (**매일 09:00**). §4 표를 파싱해 stale·데몬 사망 검사, 이상 있을 때만 텔레그램(같은 이상은 7일간 재발송 안 함). 매월 1일엔 `MAINTENANCE.md` 월간 점검 리마인더 |
|
||||
| **로그 로테이션** | `python3 ~/.openclaw/scripts/log_rotate.py [--dry-run]` | launchd `ai.openclaw.log-rotate` (매주 일요일 04:00). 30일 초과 로그 trash + 20MB 초과 활성 로그 in-place 축소 |
|
||||
| **공모주 캘린더 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/ipo_calendar_sync.py` | launchd `ai.openclaw.stock.ipo-calendar-sync` (**월·수·금 17:00**) |
|
||||
| **FOMC 캘린더 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/fomc_calendar_sync.py [--list\|--dry-run]` | launchd `ai.openclaw.stock.fomc-calendar-sync` (매월 1일 09:10). 연준 공식 일정표 → `[FOMC] YYYY-MM 회의` 종일 일정(회의 시작일 ET ~ 발표일 KST). `--list`는 파싱 결과만(캘린더 미접근) |
|
||||
| **휴장일 동기화** | `python3 ~/.openclaw/agents/stock/workspace/scripts/holiday_sync.py [--show]` | launchd `ai.openclaw.stock.holiday-sync` (매주 일요일 03:00) |
|
||||
| **Gmail 라벨 분류** | `python3 ~/.openclaw/workspace/scripts/gmail_label_classify.py [--dry-run]` | launchd `ai.openclaw.gmail-label-classify` (매일 01:00). self-sent 브리핑·종목분석·주식 리포트 메일 라벨링 + 24h 테스트메일 휴지통. LLM 미경유(`gog` CLI). 2026-06-26 cron→launchd 이관 |
|
||||
| **매매 기록 적재** | `python3 ~/.openclaw/agents/stock/workspace/scripts/trade_journal.py {collect\|seed\|show\|query} ...` | launchd `ai.openclaw.stock.trade-journal` (평일 21:00 — NXT 야간 마감 후 ka10170 4계좌 적재). `seed`는 적재 시작일 이전 보유분을 현재 평단가로 1회 시드 (idempotent) |
|
||||
| **자동매매 시뮬 스캔** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim {scan [--force]\|status\|report\|reset --yes}` | launchd `ai.openclaw.stock.sim-scan` (평일 09–15:30 매 15분, 엔진이 장외 self-skip). 가상자본 1천만 페이퍼 트레이딩, 규칙 엔진(LLM 미경유). 결과 `state/sim/`. 실주문 아님 |
|
||||
| **자동매매 시뮬 대시보드** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim.sim_web {serve\|render}` | launchd `ai.openclaw.stock.sim-web` (상시). 포트 18792 → `sim.hyowons.net`(reverse proxy 수동 등록 대기). 5탭(계좌·관심종목·시장·튜닝·백테스트), 읽기전용+`/save_params`만 쓰기 |
|
||||
| **시뮬 파라미터 스윕(튜닝 비교)** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim {sweep [frac]\|backtest}` | 트리거 없음(on-demand). 81조합 백테스트 학습/검증 비교 → `state/sim/backtest_results.json`(웹 백테스트 탭 표시). 튜닝값은 `state/sim/params.json`(웹 튜닝 탭) |
|
||||
| **자동매매 시뮬 스캔** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim {scan [--force]\|status\|report\|reset --yes}` | launchd `ai.openclaw.stock.sim-scan` (09–15:30 **매 2분**, 196엔트리 — 엔진이 장외·휴장 self-skip). 가상자본 **1억** 페이퍼 트레이딩, 규칙 엔진(LLM 미경유). 결과 `state/sim/`. 실주문 아님 |
|
||||
| **자동매매 시뮬 대시보드** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim.sim_web {serve\|render}` | launchd `ai.openclaw.stock.sim-web` (상시). 포트 18792 → `sim.hyowons.net`(reverse proxy 수동 등록 대기). **3탭**(전략·관심종목·시장 — 계좌·튜닝 탭은 2026-06-10 삭제), 읽기전용+`/save_params`만 쓰기 |
|
||||
| **시뮬 파라미터 스윕(튜닝 비교)** | `cd ~/.openclaw/agents/stock/workspace && python3 -m sim {sweep [frac]\|backtest}` | 트리거 없음(on-demand). **1536조합** 백테스트 학습/검증 비교 → `state/sim/backtest_results.json`(웹 백테스트 탭 표시). 튜닝값은 `state/sim/params.json`(웹 튜닝 탭) |
|
||||
| **Claude Code 원격 세션** (on-demand, 다중) | `python3 ~/.openclaw/workspace/skills/claude-code-session/scripts/session_tool.py {profile\|session} ...` (legacy 단일: `ensure_session.sh {open\|close\|status}`) | 세션 plist는 `~/Library/LaunchAgents/ai.claude-session.<profile>-<N>.plist`로 ephemeral, 레거시 단일은 `ai.openclaw.claude-remote-control`. 모두 RunAtLoad=false, KeepAlive=false. 클로 `claude-code-session` 스킬이 자연어 라우팅. |
|
||||
| **테일스케일 켜기·끄기** (on-demand) | `python3 ~/.openclaw/workspace/scripts/tailscale_ctl.py {on|off|status}` | 트리거 없음 — 관리자님이 "테일스케일 꺼줘/켜줘/상태" 라고 할 때 클로가 호출. sudo 불필요(localapi + sameuserproof 토큰). ⚠️ `off` 하면 타넷 경유 접근이 전부 끊긴다(stock.hyowons.net·sim.hyowons.net·폰에서 맥 직접 접근). 텔레그램은 아웃바운드라 영향 없어 되돌릴 길은 항상 남는다. behive_web 은 `BIND_HOST=''` 라 프로세스가 죽지 않고 루프백·LAN 접근은 유지. `up` 후 설정·로그인 상태는 보존되고 재인증 불필요(실측 왕복 복구 1초). ⚠️ **자동 트리거(cron/launchd)에 걸지 말 것** |
|
||||
|
||||
수동 실행: 진입점 명령만 그대로 터미널에서 실행하면 된다. launchctl 만지지 마라.
|
||||
|
||||
---
|
||||
|
||||
## 2. LLM 자연어 작업 (OpenClaw cron — `cron/jobs.json`)
|
||||
## 2. LLM 자연어 작업 (OpenClaw cron — `state/openclaw.sqlite` 의 `cron_jobs` 테이블)
|
||||
|
||||
LLM이 자막 요약·뉴스 평문 생성·결산 보고서 등 본문을 만들어야 하는 작업만 여기 남는다.
|
||||
|
||||
| 작업 | 진입점 (사람이 시키는 한 마디) | 트리거 |
|
||||
|---|---|---|
|
||||
| **오전 브리핑** (main/클로) | "오전 브리핑 메일 보내줘" → cron payload의 step 그대로 실행 | OpenClaw cron `56d66b54` (매일 07:00) |
|
||||
| **오후 브리핑** (main/클로) | "오후 브리핑 메일 보내줘" → cron payload의 step 그대로 실행 | OpenClaw cron `9d2c105f` (매일 19:00) |
|
||||
| **비하이브 종목분석 요약** (stock/레이) | "비하이브 신규 영상 점검해줘" → cron payload의 step 그대로 실행 | OpenClaw cron `747e1013` (평일 07/12/18시) |
|
||||
| **오전 브리핑** (main/클로) | "오전 브리핑 메일 보내줘" → cron payload의 step 그대로 실행 (미국증시 요약 포함) | OpenClaw cron `56d66b54` (**월~토** 07:30) + 1차폴백 `c4f2d3e5...0755` (07:55) + 2차폴백·최종 `b3f1c2d4...0830` (08:30, US 미게시여도 발송). **일요일 없음**. **토요일은 해외 전용**(스크립트가 `overseas_only` 판정 → 해외 뉴스만·일정·IPO 생략) |
|
||||
| **오후 브리핑** (main/클로) | "오후 브리핑 메일 보내줘" → cron payload의 step 그대로 실행 | OpenClaw cron `9d2c105f` (**평일** 19:00). 주말 없음 |
|
||||
| **비하이브 종목분석 요약** (stock/레이) | "비하이브 신규 영상 점검해줘" → cron payload의 step 그대로 실행 | OpenClaw cron `80052b62` (평일 07/12/18시). 등록명은 "비하이브 추천영상 요약" |
|
||||
| **월간 결산** (budget/골디) | "월간 결산 돌려줘" → cron payload의 step 그대로 실행 | OpenClaw cron `08e9a978` (매월 1일 05:00) |
|
||||
|
||||
수동 실행: 사용자가 해당 에이전트에게 작업 이름만 말하면, 에이전트는 `jobs.json`의 동일 ID 페이로드 step을 그대로 따라 실행한다. **cron 활성화 여부·스케줄 시간을 만지는 작업은 트리거 변경 요청이 명시적으로 있을 때만.**
|
||||
수동 실행: 사용자가 해당 에이전트에게 작업 이름만 말하면, 에이전트는 `openclaw cron show <id>` 의 동일 ID 페이로드 step을 그대로 따라 실행한다. **cron 활성화 여부·스케줄 시간을 만지는 작업은 트리거 변경 요청이 명시적으로 있을 때만.**
|
||||
|
||||
---
|
||||
|
||||
@@ -50,12 +60,51 @@ LLM이 자막 요약·뉴스 평문 생성·결산 보고서 등 본문을 만
|
||||
1. **트리거(언제)와 작업(무엇)은 별개다.** "지금 X 실행"은 항상 §1·§2의 진입점 명령만 실행. cron/launchd 등록·해제·재실행은 별도 요청.
|
||||
2. **payload에 "매일 X시 자동 실행" 같은 자가묘사 메타 문구를 넣지 마라.** 페이로드는 "무엇을 어떻게"만 담는다. "언제"는 schedule 필드가 단일 진실 공급원.
|
||||
3. **새 트리거를 만들 땐 단순 스크립트면 launchd, LLM이 본문을 생성해야 하면 OpenClaw cron.** 이중 등록 금지. 이관 시 한쪽 완전 제거.
|
||||
4. **Disabled 좀비 잡 즉시 제거.** `enabled:false`로 두지 말고 jobs.json에서 빼라. 검색에 잡혀서 혼선 유발.
|
||||
4. **Disabled 좀비 잡 즉시 제거.** `enabled:false`로 두지 말고 `openclaw cron delete <id>` 로 빼라. 검색에 잡혀서 혼선 유발.
|
||||
5. **트리거 추가/이관/제거는 담당 에이전트의 MEMORY.md에 반드시 기록.**
|
||||
|
||||
---
|
||||
|
||||
## 4. 알려진 이슈 / 진단 절차
|
||||
## 4. 헬스체크 (자동 감시 대상)
|
||||
|
||||
`scripts/health_check.py` 가 **이 표를 파싱해서** 돈다. 행을 고치면 감시가 바뀐다 — 표가 곧 설정이라, 트리거를 바꾸고 표를 안 고치면 알림이 떠서 드리프트가 스스로 드러난다.
|
||||
|
||||
**증거 고르는 법:** "그 잡이 돌면 **반드시** 갱신되는" 파일이어야 한다. `surge_alerts.json`·`trailing_stops.json`·`whooing_synced.json` 처럼 **일이 있을 때만** 쓰이는 파일은 조용한 날 stale 로 보여 오탐이 된다 — 그런 잡은 로그 파일을 증거로 쓴다. 반대로 `holiday_sync` 처럼 "돌긴 도는데 결과가 옛것"인 실패(2026-07~09 2개월 stale)를 잡으려면 로그가 아니라 **결과 파일**이어야 한다.
|
||||
|
||||
**기대 주기:** 평일 전용 잡은 금→월 72h 에 여유를 더해 `100h`. ⚠️ 설·추석 등 3일 초과 연휴엔 오탐이 날 수 있다(휴장일 보정은 넣지 않았다 — 연 2회 오탐을 감수하는 대신 로직을 단순하게 뒀다). `daemon:` 접두는 주기 대신 **프로세스 생존**(PID)을 본다.
|
||||
|
||||
| 작업 | 증거 (mtime 기준) | 기대 주기 |
|
||||
|---|---|---|
|
||||
| OpenClaw Gateway | `daemon:ai.openclaw.gateway` | - |
|
||||
| 비하이브 웹뷰 | `daemon:ai.openclaw.stock.behive-web` | - |
|
||||
| 시뮬 대시보드 | `daemon:ai.openclaw.stock.sim-web` | - |
|
||||
| 웹 채팅 | `daemon:ai.openclaw.chat-web` | - |
|
||||
| 후잉 동기화 | `logs/whooing-sync.log` | 2h |
|
||||
| codex 폴백 감시 | `logs/codex-fallback-monitor.log` | 2h |
|
||||
| 급등락 알림 | `logs/stock-surge-monitor.log` | 100h |
|
||||
| 트레일링 스톱 감시 | `agents/stock/workspace/state/trailing_monitor_heartbeat` | 100h |
|
||||
| 워치리스트 모니터링 | `logs/watchlist-monitor.log` | 100h |
|
||||
| 자동매매 시뮬 스캔 | `agents/stock/workspace/state/sim/last_scan.json` | 100h |
|
||||
| 주식 포트폴리오 스냅샷 | `agents/stock/workspace/state/portfolio_daily_snapshot.json` | 100h |
|
||||
| 매매 기록 적재 | `agents/stock/workspace/state/trade_journal.jsonl` | 100h |
|
||||
| 시장지표 적재 | `agents/stock/workspace/state/market_indicators_history.jsonl` | 100h |
|
||||
| Gmail 라벨 분류 | `workspace/state/gmail_label_classify.log` | 30h |
|
||||
| git 자동 백업 | `logs/git-autopush.log` | 30h |
|
||||
| 휴장일 동기화 | `agents/stock/workspace/state/market_holidays.json` | 8d |
|
||||
| 공모주 캘린더 동기화 | `agents/stock/workspace/state/ipo_calendar_sync.json` | 5d |
|
||||
| FOMC 캘린더 동기화 | `agents/stock/workspace/state/fomc_calendar_sync.json` | 35d |
|
||||
| 증권잔액 → 골디 inbox | `logs/send-balance.log` | 35d |
|
||||
| 로그 로테이션 | `logs/log-rotate.log` | 8d |
|
||||
|
||||
감시하지 **않는** 것과 이유:
|
||||
- **OpenClaw cron 4종**(브리핑·비하이브·월간결산) — 실패하면 메일·텔레그램이 안 와서 관리자님이 바로 아신다. 게다가 `openclaw cron list` 의 `Last`/`Status` 가 이미 이력을 들고 있다
|
||||
- **시뮬 스윕·골디 inbox 처리** — on-demand 라 기대 주기가 없다
|
||||
- **`sim.backfill_flow`·`sim universe`** — `stock.trade-journal` plist 가 묶어 도는 5개 명령 중 3·4번째다(5번째는 `kiwoom_client refresh-codes --if-stale 7` — 종목코드 캐시 주 1회 갱신, 2026-09-10 추가. 이 캐시의 `nxt_enable` 이 자산웹 NXT 판정 근거라 낡으면 🔥배지·거래가능 초록점이 틀어진다. 금액엔 영향 없음). 앞 2개(매매기록·시장지표)는 위에서 감시하지만 이 둘은 뺐다 — 결과물(`flow_history.sqlite`·`auto_seen.json`)이 stale 해도 백테스트 수급게이트가 중립으로 비활성되고 sim 관찰목록 자동편입이 멈출 뿐, **실매매·실주문에는 영향이 없다**. ⚠️ 다만 plist 는 `하나 실패해도 나머지 시도` 구조라 이 둘만 조용히 죽을 수 있다 — sim 백테스트 결과가 이상하면 여기부터 본다
|
||||
- **Claude Code 원격 세션** — ephemeral
|
||||
|
||||
---
|
||||
|
||||
## 5. 알려진 이슈 / 진단 절차
|
||||
|
||||
- **bonjour 광고 stuck → gateway crash 루프** (2026-04-28 한 차례 발생, 현재는 정상)
|
||||
- 증상: `~/.openclaw/logs/stability/` 에 `*unhandled_rejection.json` 누적, gateway PID 계속 변경, `openclaw cron list`가 `gateway closed (1006)` 응답
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"setupCompletedAt": "2026-04-23T06:17:48.250Z"
|
||||
}
|
||||
@@ -105,3 +105,96 @@ LLM-LLM 자연어 통신은 프롬프트 인젝션·할루시네이션 증폭
|
||||
## 직접 고쳐 써라
|
||||
|
||||
이 문서는 출발점이다. 패턴이 보이면 직접 업데이트해라. 정비는 코디가 하지만, 이 매뉴얼의 작가는 너다.
|
||||
|
||||
## Tools
|
||||
|
||||
### Local notes (migrated from TOOLS.md)
|
||||
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
가계부 관련 파일·스크립트·발신번호 메모. 모든 경로는 `agents/budget/workspace/` 기준.
|
||||
|
||||
## Whooing
|
||||
|
||||
- 가계부 시스템: https://whooing.com
|
||||
- **웹훅 URL:** `/Users/snowoyh/.openclaw/credentials/whooing.json` 의 `webhook_url` 필드.
|
||||
형식: `https://whooing.com/webhook/s/xxxx-xxxx-xxxx-xxxx`
|
||||
- 웹훅 페이로드 종류:
|
||||
- **raw 문자**: `message=<원문>` 보내면 후잉이 알아서 파싱
|
||||
- **구조화 JSON**: `{entry_date, item, money, left, right, memo}` (left=차변, right=대변)
|
||||
- 결제 취소/승인취소도 같이 전송해야 후잉에서 -금액으로 상쇄됨.
|
||||
|
||||
## State
|
||||
|
||||
- `state/whooing_synced.json` — `{ "last_message_at": <ISO>, "last_synced_at": "ISO" }`. dedupe 기준.
|
||||
- `state/whooing_account_map.json` — 발신번호 → 후잉 매핑. `confirmed: true`인 엔트리만 후잉으로 자동 전송. `confirmed: false`는 발견 시 로그만 남기고 사용자 확인 대기.
|
||||
- `state/whooing_accounts.json` — **관리자님 후잉 계정과목 차트** (자산/부채/순자산/비용/수익 + carrier→결제수단 매핑). 구조화 입력으로 left/right 채울 때 항상 이 파일에서 정확한 계정명을 끌어쓴다. 차트에 없는 계정명은 후잉이 거부하므로 절대 추측해 보내지 말 것.
|
||||
- `state/whooing_failures.json` — 후잉이 4xx/5xx로 거절한 건. 수동 확인용.
|
||||
|
||||
## Scripts (python3)
|
||||
|
||||
- `skills/whooing-sync/scripts/whooing_sync.py` — iMessage 결제문자 → 후잉 웹훅 POST. launchd `ai.openclaw.budget.whooing-sync` 가 매시 0/15/30/45분 실행 (OpenClaw cron 아님). ⚠️ **on-demand 실행은 스크립트 직접 호출이 아니라 `launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync` + 로그 확인** — 내 세션은 게이트웨이 node 를 TCC responsible process 로 물고 있어 `imsg` 가 Messages DB 를 못 읽는다. FDA 필수(`/opt/homebrew/bin/imsg`). 페어 매칭 로직 내장 — 자세한 건 `skills/whooing-sync/SKILL.md`.
|
||||
- `skills/whooing-sync/scripts/imsg_cli.py` — `imsg` CLI 공용 래퍼(`run_json`/`chats`). stderr·returncode 를 판정해 FDA 누락을 파싱 에러로 둔갑시키지 않는다. `whooing_sync.py`·`gahee_reminder.py` 가 공유.
|
||||
- `skills/whooing-sync/scripts/whooing_manual.py` — iMessage 없이 한 건 직접 등록. structured(`--item/--money/--left/--right [--date] [--memo]`) 또는 raw(`--message`). structured는 `whooing_accounts.json` 차트 검증 후 POST.
|
||||
- `skills/whooing-sync/scripts/whooing_balance.py` — 후잉 OpenAPI로 자산/부채/자본 잔액 조회. 옵션: `--section-id`, `--as-of YYYY-MM-DD`, `--json`. 크리덴셜은 `credentials/whooing.json`의 `api` 블록(app_id/token/signature).
|
||||
|
||||
## 발신번호
|
||||
|
||||
`state/whooing_account_map.json`이 정답. 아래는 사람이 읽기 쉬운 요약.
|
||||
|
||||
**확인됨 (관리자님이 직접 확인):**
|
||||
|
||||
| 발신처 | 발신번호 |
|
||||
|--------|---------|
|
||||
| 하나 계좌이체 | `+8215991111` |
|
||||
| 신한은행 | `+8215778000` |
|
||||
| 신한카드 | `+8215447000` (※ 과거 오타 `+8215447200` 쓴 적 있음 — 실사용 X, 현재 confirmed=false 로 대기) |
|
||||
|
||||
**추정 (모델 사전지식 기반, 실제 메시지 들어오면 confirmed로 승격 필요):**
|
||||
|
||||
| 발신처 | 추정 발신번호 |
|
||||
|--------|--------------|
|
||||
| 삼성카드 | `+8215888900` |
|
||||
| KB국민카드 | `+8215881688` |
|
||||
| 현대카드 | `+8215776200` |
|
||||
| 롯데카드 | `+8215888100` |
|
||||
| 하나카드 | `+8218001111` |
|
||||
| 우리카드 | `+8215889955` |
|
||||
| BC카드 | `+8215884000` |
|
||||
| NH농협카드 | `+8216444000` |
|
||||
| 카카오페이 | `+8216447405` |
|
||||
| 네이버페이 | `+8215883819` |
|
||||
| 토스 | `+8215994905` |
|
||||
| KB국민은행 | `+8215889999` |
|
||||
| 우리은행 | `+8215885000` |
|
||||
| NH농협은행 | `+8216613000` |
|
||||
| 카카오뱅크 | `+8215993333` |
|
||||
|
||||
**정책:** 미확인 번호 메시지가 들어오면 무시 또는 `failures.json`에 "unmapped"로 기록만 한다. 잘못된 자동 등록을 막기 위함.
|
||||
|
||||
## 관리자님 관련
|
||||
|
||||
- 가계부 질문이 들어오면 먼저 `state/whooing_synced.json`과 `whooing_failures.json`을 본다.
|
||||
- 잔액·계정 총합은 `whooing_balance.py`로 조회한다. 후잉 OpenAPI(`bs.json`/`accounts.json`/`sections.json`) 연동 완료.
|
||||
|
||||
## Playwright MCP (브라우저 자동화)
|
||||
|
||||
OpenClaw가 `openclaw.json` 의 MCP 서버 `playwright`로 노출. 도구는 `playwright__browser_*` prefix로 약 23개.
|
||||
|
||||
- **탐색:** `navigate / navigate_back / tabs / wait_for`
|
||||
- **관찰:** `snapshot` (접근성 트리 — 클릭 대상 ref 식별에 **우선 사용**) / `take_screenshot` / `console_messages` / `network_requests` / `network_request`
|
||||
- **조작:** `click / type / fill_form / press_key / hover / select_option / drag / drop / file_upload / handle_dialog / resize / close`
|
||||
- **평가:** `evaluate` (페이지 JS 컨텍스트) / `run_code_unsafe` (Playwright 코드 직접 — **RCE 등급, 마지막 수단**)
|
||||
|
||||
**표준 흐름:** `navigate` → `snapshot`으로 ref 확보 → `click`/`type`/`fill_form` → `wait_for`로 결과 대기 → `snapshot` 또는 `network_requests`로 검증. 스크린샷은 사람 보고용이지 다음 동작 분기 근거로 쓰지 말 것.
|
||||
|
||||
**운영 특성:**
|
||||
- 기본값 `--headless --isolated`. 매 세션 쿠키·로그인 폐기.
|
||||
- 영속 로그인 필요한 사이트는 시도 전 **코디에게 `user-data-dir` 분리·등록 요청**.
|
||||
- 첫 호출 시 npx spawn 1~2초 지연.
|
||||
- 은행·카드사 공동인증서·간편비밀번호 화면이 나오면 **즉시 중단하고 관리자님께 보고**, 우회 시도 X.
|
||||
|
||||
**활용 시나리오 (골디 맥락):**
|
||||
- 후잉 웹 직접 분개 수정/삭제 — 현재 inbox·webhook 단방향 한계 보완용. **영속 로그인 필요** → 코디에게 user-data-dir 분리 요청 후 진행
|
||||
- 카드사 웹 청구서 PDF 수집 (월별 명세 검증·보조). 공동인증서·간편비번 화면 진입 시 중단
|
||||
- 우선순위는 항상 **API · iMessage 결제문자 → 후잉 webhook 자동 동기화**. 브라우저는 자동 흐름이 못 메꾸는 잔여 영역만
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
- **스케줄러는 launchd** (OpenClaw cron 아님). plist: `~/Library/LaunchAgents/ai.openclaw.budget.whooing-sync.plist`, label `ai.openclaw.budget.whooing-sync`, 매시 0/15/30/45분. 로그는 `/Users/snowoyh/.openclaw/logs/whooing-sync.{log,err.log}`. OpenClaw 의 `후잉 가계부 동기화` cron 잡은 의도적으로 `enabled: false` — LLM 세션 비용(월 ~70M 토큰) 때문에 전환된 것. **다시 enable 하지 말 것.**
|
||||
- **FDA 필수**: launchd 는 FDA 상속 안 받음. `/opt/homebrew/bin/imsg` 를 "시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한" 에 등록해야 Messages DB 읽힘. 누락 시 `imsg chats 실행 실패` stderr + stdout "새 결제 메시지 없음" 으로 조용히 넘어감. 맥 이전·imsg 재설치 시 재등록.
|
||||
- **페어 매칭**: `whooing_sync.py` 가 동일 금액 입↔출 SMS 를 5분 윈도우(`PAIR_WINDOW_SECONDS=300`) 로 짝지어 1건의 자산↔자산 structured 이체로 합성. 짝 없는 입출금이 5분 이내(`HOLD_GRACE_SECONDS=300`) 면 hold → 다음 cron 재시도. 상세는 SKILL.md 의 "페어 매칭" 섹션.
|
||||
|
||||
- **증권 이체 매칭** (2026-09-10): 은행→증권 이체는 은행 SMS 에 **입금자명만** 찍혀 짝이 없다. `securities_match.py` 가 capital-block 직전에 키움 kt00015 에서 짝(같은 날·같은 금액·반대 방향·`is_principal_flow` 적요)을 찾아 `증권(효원) ← 하나은행(효원)` 이체로 분개한다. **평시 키움 콜 0**(자본 분개로 갈 뻔한 건이 있을 때만 4콜). 애매하면(후보 여럿·조회 실패) 매칭 안 하고 기존 알림 경로로 보낸다. 상세는 SKILL.md 의 "증권 이체 매칭" 섹션.
|
||||
- **비결제/비거래 메시지 자동 스킵**: `SKIP_PATTERNS` 에 `"인증번호"`, `"간편인증서"`, `"개설되었습니다"`, `"계좌개설"` 포함. 또한 금액 패턴(`숫자원`)이 없는 문자는 후잉 raw 폴백으로 보내지 않는다. 인증/간편인증서/계좌개설 안내 문자는 후잉 처리하지 않는다.
|
||||
|
||||
## 발신번호 주의
|
||||
@@ -27,9 +29,9 @@
|
||||
- 신한은행: `+8215778000` (1577-8000).
|
||||
- 하나 계좌이체: `+8215991111`.
|
||||
|
||||
## merchant_map 한계 (미해결)
|
||||
## merchant_map 한계 (일부 해소)
|
||||
|
||||
`exact: "방효원"` 룰은 본인 이름만으론 이체 상대계좌를 식별 못 함. 페어 매칭이 1차 방어선이지만, 한쪽 은행이 confirmed=false 거나 시간창 벗어나면 이 룰이 폴백으로 타서 **기초잔액(효원) 경유로 잘못 기록**될 수 있다. 발견 시 `whooing_manual.py` 로 역분개하거나 후잉 UI 에서 삭제 후 올바른 이체 1건으로 재등록. 근본 해결은 메모에 "카뱅/신한" 같은 목적지 키워드를 일관되게 넣고 `merchant_map.contains` 에 등록하는 것.
|
||||
`exact: "방효원"` 룰은 본인 이름만으론 이체 상대계좌를 식별 못 함. ⚠️ **상대가 증권 계좌인 경우는 2026-09-10 증권 이체 매칭으로 해소**됐다(키움 kt00015 가 짝을 알려준다). 아래는 그 외 상대계좌(다른 은행·타인 송금) 이야기다. 페어 매칭이 1차 방어선이지만, 한쪽 은행이 confirmed=false 거나 시간창 벗어나면 이 룰이 폴백으로 타서 **기초잔액(효원) 경유로 잘못 기록**될 수 있다. 발견 시 `whooing_manual.py` 로 역분개하거나 후잉 UI 에서 삭제 후 올바른 이체 1건으로 재등록. 근본 해결은 메모에 "카뱅/신한" 같은 목적지 키워드를 일관되게 넣고 `merchant_map.contains` 에 등록하는 것.
|
||||
|
||||
## 우선 룰 (whooing_overrides.json) — 2026-04-29 도입
|
||||
|
||||
@@ -68,5 +70,8 @@
|
||||
- **이미지 답신**: 자동분개 X. 골디 텔레그램 알림만 — 관리자님이 직접 처리하거나 가희님께 텍스트로 다시 요청.
|
||||
- **포맷 오류** (페어 0건): 분개 중단 + 골디 텔레그램 보고. 가희님께 다시 요청 또는 직접 입력.
|
||||
- **완료**: 골디 텔레그램으로 합계·차액·분개 방향 보고.
|
||||
- 상태 파일: `state/gahee_reminder.json` (last_sent_month, last_processed_message_at), 수신자: `credentials/gahee_imessage.json`.
|
||||
- 상태 파일: `state/gahee_reminder.json` (last_sent_month, last_processed_message_at, last_sent_at, reply_alert_for), 수신자: `credentials/gahee_imessage.json`.
|
||||
- 발신 문구 수정은 state 파일 `message_template` 직접 편집 (코드 재배포 불필요).
|
||||
- **발신 경로는 `--service imessage`** (2026-08-03 전환). 이전 `sms` 강제는 이 수신자에게 3전 3패였고(전부 `error=4` 미발송), 살아남은 건은 Messages 가 RCS 로 바꿔준 운이었다. 7/25 미발송이 성공으로 기록돼 7월 잔액이 통째로 누락된 사고의 원인.
|
||||
- **답신 미도착 감시**: 발신 후 2일간 답신 없으면 골디 텔레그램 1회 경고. `imsg send` 는 발송 실패를 알려주지 않아(rc=0) 이게 유일한 안전망이다. 재발신은 자동 X — `python3 gahee_reminder.py --send-now` 로 수동.
|
||||
- ⚠️ `--send-now` 는 **내 세션에서 못 돌린다** (자동화 → 메시지 권한이 Claude Code 에 거부). launchd 임시 oneshot plist 경유로 실행. 상세는 SKILL.md.
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
가계부 관련 파일·스크립트·발신번호 메모. 모든 경로는 `agents/budget/workspace/` 기준.
|
||||
|
||||
## Whooing
|
||||
|
||||
- 가계부 시스템: https://whooing.com
|
||||
- **웹훅 URL:** `/Users/snowoyh/.openclaw/credentials/whooing.json` 의 `webhook_url` 필드.
|
||||
형식: `https://whooing.com/webhook/s/xxxx-xxxx-xxxx-xxxx`
|
||||
- 웹훅 페이로드 종류:
|
||||
- **raw 문자**: `message=<원문>` 보내면 후잉이 알아서 파싱
|
||||
- **구조화 JSON**: `{entry_date, item, money, left, right, memo}` (left=차변, right=대변)
|
||||
- 결제 취소/승인취소도 같이 전송해야 후잉에서 -금액으로 상쇄됨.
|
||||
|
||||
## State
|
||||
|
||||
- `state/whooing_synced.json` — `{ "last_message_at": <ISO>, "last_synced_at": "ISO" }`. dedupe 기준.
|
||||
- `state/whooing_account_map.json` — 발신번호 → 후잉 매핑. `confirmed: true`인 엔트리만 후잉으로 자동 전송. `confirmed: false`는 발견 시 로그만 남기고 사용자 확인 대기.
|
||||
- `state/whooing_accounts.json` — **관리자님 후잉 계정과목 차트** (자산/부채/순자산/비용/수익 + carrier→결제수단 매핑). 구조화 입력으로 left/right 채울 때 항상 이 파일에서 정확한 계정명을 끌어쓴다. 차트에 없는 계정명은 후잉이 거부하므로 절대 추측해 보내지 말 것.
|
||||
- `state/whooing_failures.json` — 후잉이 4xx/5xx로 거절한 건. 수동 확인용.
|
||||
|
||||
## Scripts (python3)
|
||||
|
||||
- `skills/whooing-sync/scripts/whooing_sync.py` — iMessage 결제문자 → 후잉 웹훅 POST. launchd `ai.openclaw.budget.whooing-sync` 가 매시 0/15/30/45분 실행 (OpenClaw cron 아님). FDA 필수(`/opt/homebrew/bin/imsg`). 페어 매칭 로직 내장 — 자세한 건 `skills/whooing-sync/SKILL.md`.
|
||||
- `skills/whooing-sync/scripts/whooing_manual.py` — iMessage 없이 한 건 직접 등록. structured(`--item/--money/--left/--right [--date] [--memo]`) 또는 raw(`--message`). structured는 `whooing_accounts.json` 차트 검증 후 POST.
|
||||
- `skills/whooing-sync/scripts/whooing_balance.py` — 후잉 OpenAPI로 자산/부채/자본 잔액 조회. 옵션: `--section-id`, `--as-of YYYY-MM-DD`, `--json`. 크리덴셜은 `credentials/whooing.json`의 `api` 블록(app_id/token/signature).
|
||||
|
||||
## 발신번호
|
||||
|
||||
`state/whooing_account_map.json`이 정답. 아래는 사람이 읽기 쉬운 요약.
|
||||
|
||||
**확인됨 (관리자님이 직접 확인):**
|
||||
|
||||
| 발신처 | 발신번호 |
|
||||
|--------|---------|
|
||||
| 하나 계좌이체 | `+8215991111` |
|
||||
| 신한은행 | `+8215778000` |
|
||||
| 신한카드 | `+8215447000` (※ 과거 오타 `+8215447200` 쓴 적 있음 — 실사용 X, 현재 confirmed=false 로 대기) |
|
||||
|
||||
**추정 (모델 사전지식 기반, 실제 메시지 들어오면 confirmed로 승격 필요):**
|
||||
|
||||
| 발신처 | 추정 발신번호 |
|
||||
|--------|--------------|
|
||||
| 삼성카드 | `+8215888900` |
|
||||
| KB국민카드 | `+8215881688` |
|
||||
| 현대카드 | `+8215776200` |
|
||||
| 롯데카드 | `+8215888100` |
|
||||
| 하나카드 | `+8218001111` |
|
||||
| 우리카드 | `+8215889955` |
|
||||
| BC카드 | `+8215884000` |
|
||||
| NH농협카드 | `+8216444000` |
|
||||
| 카카오페이 | `+8216447405` |
|
||||
| 네이버페이 | `+8215883819` |
|
||||
| 토스 | `+8215994905` |
|
||||
| KB국민은행 | `+8215889999` |
|
||||
| 우리은행 | `+8215885000` |
|
||||
| NH농협은행 | `+8216613000` |
|
||||
| 카카오뱅크 | `+8215993333` |
|
||||
|
||||
**정책:** 미확인 번호 메시지가 들어오면 무시 또는 `failures.json`에 "unmapped"로 기록만 한다. 잘못된 자동 등록을 막기 위함.
|
||||
|
||||
## 관리자님 관련
|
||||
|
||||
- 가계부 질문이 들어오면 먼저 `state/whooing_synced.json`과 `whooing_failures.json`을 본다.
|
||||
- 잔액·계정 총합은 `whooing_balance.py`로 조회한다. 후잉 OpenAPI(`bs.json`/`accounts.json`/`sections.json`) 연동 완료.
|
||||
|
||||
## Playwright MCP (브라우저 자동화)
|
||||
|
||||
OpenClaw가 `openclaw.json` 의 MCP 서버 `playwright`로 노출. 도구는 `playwright__browser_*` prefix로 약 23개.
|
||||
|
||||
- **탐색:** `navigate / navigate_back / tabs / wait_for`
|
||||
- **관찰:** `snapshot` (접근성 트리 — 클릭 대상 ref 식별에 **우선 사용**) / `take_screenshot` / `console_messages` / `network_requests` / `network_request`
|
||||
- **조작:** `click / type / fill_form / press_key / hover / select_option / drag / drop / file_upload / handle_dialog / resize / close`
|
||||
- **평가:** `evaluate` (페이지 JS 컨텍스트) / `run_code_unsafe` (Playwright 코드 직접 — **RCE 등급, 마지막 수단**)
|
||||
|
||||
**표준 흐름:** `navigate` → `snapshot`으로 ref 확보 → `click`/`type`/`fill_form` → `wait_for`로 결과 대기 → `snapshot` 또는 `network_requests`로 검증. 스크린샷은 사람 보고용이지 다음 동작 분기 근거로 쓰지 말 것.
|
||||
|
||||
**운영 특성:**
|
||||
- 기본값 `--headless --isolated`. 매 세션 쿠키·로그인 폐기.
|
||||
- 영속 로그인 필요한 사이트는 시도 전 **코디에게 `user-data-dir` 분리·등록 요청**.
|
||||
- 첫 호출 시 npx spawn 1~2초 지연.
|
||||
- 은행·카드사 공동인증서·간편비밀번호 화면이 나오면 **즉시 중단하고 관리자님께 보고**, 우회 시도 X.
|
||||
|
||||
**활용 시나리오 (골디 맥락):**
|
||||
- 후잉 웹 직접 분개 수정/삭제 — 현재 inbox·webhook 단방향 한계 보완용. **영속 로그인 필요** → 코디에게 user-data-dir 분리 요청 후 진행
|
||||
- 카드사 웹 청구서 PDF 수집 (월별 명세 검증·보조). 공동인증서·간편비번 화면 진입 시 중단
|
||||
- 우선순위는 항상 **API · iMessage 결제문자 → 후잉 webhook 자동 동기화**. 브라우저는 자동 흐름이 못 메꾸는 잔여 영역만
|
||||
@@ -1,3 +1,4 @@
|
||||
# 2026-05-07
|
||||
|
||||
- 관리자님 지시: `삼천리도시가스`는 후잉에서 `주거,관리` 항목으로 분류하고, item은 `공과금`으로 처리.
|
||||
- ⚠️ **2026-08-06 정정: 계정은 `주거,통신`** — 후잉 차트에 `주거,관리` 계정이 존재하지 않는다(그래서 이 날 실제 입력분도 `주거,통신`으로 들어갔다). item `공과금`은 그대로. 상세는 `memory/2026-08-06.md`.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# 2026-06-20
|
||||
|
||||
- 신한은행 잔액 불일치 `144원`은 관리자님 확인으로 은행이자였다. 후잉에 `신한은행(효원) ← 기타수익`, item `이자`, memo `은행이자`, 금액 `144원`, 날짜 `2026-06-20`로 수동 등록했고, 후잉 잔액이 SMS 잔액 `307,004원`과 일치함을 검증했다. `state/whooing_balance_alerts.json`의 신한은행 정합성 상태도 diff `0`으로 정리했다.
|
||||
- 관리자님 지시로 신한은행도 잔액 불일치 자동 이자 보정 대상에 추가했다. `whooing_sync.py` 정합성 체크에서 자산 계정이고 후잉 잔액이 SMS보다 부족하며 차이가 `5,000원` 이하이면 카카오뱅크와 동일하게 `{은행계정} ← 기타수익`, item `이자`, memo `은행이자`로 자동 분개한다.
|
||||
@@ -0,0 +1,27 @@
|
||||
# 2026-08-06
|
||||
|
||||
## 신한카드 삼천리도시가스 분류 실패 — 원인 3겹 (코디 수정)
|
||||
|
||||
가스요금 14,000원 SMS가 structured 분류를 통과하지 못하고 raw 폴백 → **후잉에 거래가 아예 생성되지 않았다**(후잉 자체 파서도 이 형식을 못 읽음). 8/6 후잉 거래에 복권·톨비·쿠팡 3건만 있었음.
|
||||
|
||||
1. **파서 공백** — 신한카드 `자동납부` SMS는 기존 승인 SMS와 **금액/상호 순서가 반대**다.
|
||||
- 기존: `신한카드(6847)승인 방*원님 아파트 관리비 184,440원 정상승인 되었습니다.` (상호 → 금액)
|
||||
- 자동납부: `[신한카드] 자동납부 정상승인 방효원님 08/06 (일시불) 14,000원 삼천리도시가스` (금액 → 상호)
|
||||
|
||||
`SHINHAN_APPROVE_RE`가 `[신한카드]`의 `]`에서 막혀 처음부터 한 번도 파싱된 적 없음(2026-05-07·08-06 두 건 전부 raw). `parsers.py`에 `SHINHAN_AUTOPAY_RE` 신설, `parse_shinhan_card`가 자동납부 → 일반 순으로 시도.
|
||||
⚠️ `SHINHAN_APPROVE_RE`를 느슨하게 고치는 건 안 된다 — merchant 자리에 `08/06 (일시불)`이 잡힌다.
|
||||
|
||||
2. **계정 `주거,관리`는 후잉에 없다** — `삼천리도시가스` 매핑이 이 죽은 계정을 가리키고 있었다. 2026-06-30 `주택임대료`가 같은 계정으로 `left account does not exist at entry_date` 실패한 그 계정. **`주거,통신` / item `공과금`으로 확정**(2026-05-07 실제 입력분·`도시가스` 기본 매핑·주택임대료 정정 선례와 동일). 2026-05-07 메모의 `주거,관리` 지시는 그 메모에 정정 표기함.
|
||||
|
||||
3. **`state/whooing_accounts.json`이 stale**(updated_at 2026-04-23) — `whooing_manual.py`의 차트 검증이 이 파일을 보는데 죽은 계정 `주거,관리`·`카뱅지금통`(실제는 `카뱅저금통`)을 싣고 있어 로컬 검증만 통과하고 후잉에서 터졌다. 라이브 `accounts.json`으로 전체 동기화(자산 +7/−1, 부채 +2, 비용 −1, updated_at 2026-08-06). 매핑 계정을 늘릴 때는 이 스냅샷이 최신인지 먼저 확인할 것.
|
||||
|
||||
누락분 14,000원은 `whooing_manual.py`로 보정 입력 완료(`20260806 / 공과금 / 주거,통신 ← 신한신용(효원)`).
|
||||
|
||||
## ⚠️ raw 폴백 200은 "후잉에 기록됨"이 아니다
|
||||
|
||||
후잉 raw 엔드포인트는 **자기 파서가 실패해도 200을 준다**. `✅ [raw] … 200` 로그와 `sent_raw` 카운트는 "접수됨"일 뿐이라, 이번 누락이 5월부터 안 보인 이유다. raw 폴백 알림을 받으면 **후잉에 실제로 줄이 생겼는지 `entries.json`으로 확인**해야 한다. (`imsg send`의 rc=0이 발송 성공이 아닌 것과 같은 함정.)
|
||||
|
||||
## 범위 밖 (관리자님 판단 대기)
|
||||
|
||||
- **현대카드 자동납부도 같은 파서 공백** — `[현대카드] 자동납부 승인 방*원님 라이나생명 31,100원`(2026-05-17 raw). 다만 `whooing_sync.py`의 잔액차 기반 월 1회 자동보정 + `_should_skip_hyundai_insurance_duplicate`가 이미 커버해 실질 누락 없음. 미수정.
|
||||
- raw 폴백분이 후잉에 실제 반영됐는지 자동 검출하는 장치는 붙이지 않았다.
|
||||
@@ -0,0 +1 @@
|
||||
- 관리자님 지정: `쿠팡페이주식회사`도 쿠팡 구매로 보고 후잉 자동분류는 `생활용품 ← {결제수단}`으로 처리한다. 쿠팡이츠·쿠팡플레이와 섞이지 않게 `state/whooing_merchant_map.json` exact 룰로만 등록했다.
|
||||
@@ -0,0 +1,3 @@
|
||||
- 현대카드 자동납부 문자 `[현대카드] 자동납부 승인 ... 라이나생명 31,100원`이 raw 폴백으로 들어간 원인을 확인했다. 기존 구조는 라이나생명 SMS 자체를 파싱하지 않고 현대카드 누적액-후잉 잔액 차이 `-31,100원`일 때 월 1회 자동보정하는 방식이라, 차이가 이미 사라진 2026-08 건은 raw 전송으로 빠졌다.
|
||||
- 조치: `skills/whooing-sync/scripts/parsers.py`에 현대카드 자동납부 한 줄 형식 파서를 추가하고, `state/whooing_overrides.json`에 `현대카드 라이나생명 보험료 자동납부` 룰을 추가했다. 이후 같은 문자는 `의료,건강,보험 ← 현대신용(효원)`, item=`보험료`, amount=`31,100`으로 structured 처리된다. 기존 월 1회 잔액차 자동보정 및 보정 후 지연 SMS skip은 중복 방지용으로 유지.
|
||||
- 후속 확인: 2026-08-19 `현대 블루멤버스 승인 ... 쿠팡페이주식회사 18,500원`도 raw 200이었지만 후잉 잔액에 반영되지 않았다. 2026-08-19 쿠팡 18,500원(`생활용품 ← 현대신용(효원)`)과 2026-08-20 라이나생명 31,100원(`의료,건강,보험 ← 현대신용(효원)`)을 structured 수동 반영했고, 현대신용 잔액이 `1,104,760원`에서 `1,154,360원`으로 증가해 기대값과 일치함을 확인했다. `parsers.py`는 `현대 블루멤버스`처럼 "카드"가 빠진 현대카드 문자도 파싱하도록 추가 보강했다.
|
||||
@@ -0,0 +1,4 @@
|
||||
# 2026-08-29
|
||||
|
||||
- 관리자님 지정: 현대카드 결제내역 가맹점명이 정확히 `쿠팡`이면 배달로 처리한다. 후잉 계정과목에는 `배달` 비용계정이 없으므로 자동등록은 `식비` left + item `배달`로 보낸다.
|
||||
- `쿠팡페이주식회사` 표기는 기존처럼 생활용품 처리한다. 월 29일 7,890원 쿠팡페이 정기결제 예외는 기존 `지식,문화` + item `계정결제_쿠팡` 유지.
|
||||
@@ -0,0 +1 @@
|
||||
- 하나은행 2026-09-10 09:27 `방효원` 3,000,000원 출금은 관리자님 확인으로 주식계좌 이체였다. 후잉에 `증권(효원) ← 하나은행(효원)`, item `증권이체`, memo 원문으로 수동 등록했고 응답 200/done 확인. 등록 후 하나은행 후잉 잔액 `35,689,680원`이 SMS 잔액과 일치해 `state/whooing_failures.json`의 `capital_blocked` 보류 건을 비웠다.
|
||||
@@ -0,0 +1,3 @@
|
||||
# 2026-10-07
|
||||
|
||||
- 관리자님 확인: 2026-10-07 10:56경 하나은행 900,000원 출금(`카뱅오픈방효원`)은 비용이 아니라 **증권계좌 이체**. 자동 동기화의 `기타비용 ← 하나은행(효원)` 오분류를 관리자님 요청으로 거래 ID 1231373에서 `증권(효원) ← 하나은행(효원)`으로 직접 수정함. 후잉 API 재조회로 방향·금액 확인 완료.
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: monthly-settlement
|
||||
description: 매월 1일 후잉 잔액을 스냅샷으로 저장하고 전월 스냅샷과 비교해 자산 변동 리포트를 생성·발송한다. "이번달 결산", "월간 결산", "자산 변동 요약" 요청에 사용. 매월 1일 05:00 KST cron(agent: budget) 자동 실행.
|
||||
---
|
||||
|
||||
# monthly-settlement
|
||||
|
||||
매월 1일에 후잉 잔액을 스냅샷으로 저장하고, 전월 스냅샷과 비교해 자산 변동을 리포트한다.
|
||||
|
||||
@@ -43,11 +43,13 @@ BALANCE_SCRIPT = WORKSPACE / 'skills' / 'whooing-sync' / 'scripts' / 'whooing_ba
|
||||
TELEGRAM_ACCOUNT = 'budget'
|
||||
|
||||
KNOWN_TOPICS = {'securities_balance'}
|
||||
SUPPORTED_SCHEMA = {1}
|
||||
SUPPORTED_SCHEMA = {1, 2}
|
||||
|
||||
SECURITIES_ASSET_NAME = '증권(효원)'
|
||||
SECURITIES_ASSET_NAME = '증권(효원)' # v1(단일 소유자) 경로 및 self 자산명
|
||||
SECURITIES_GAIN_NAME = '주식평가수익'
|
||||
SECURITIES_LOSS_NAME = '주식평가손실'
|
||||
# 소유자 → 후잉 자산명 (v2). 손익 계정(주식평가수익/손실)은 양쪽 공용.
|
||||
OWNER_ASSET = {'self': '증권(효원)', 'gahee': '증권(가희)'}
|
||||
|
||||
RECONCILE_NOISE_FLOOR = 10_000 # |차액| < 1만원: 노이즈, 분개 skip
|
||||
RECONCILE_HARD_CAP = 100_000_000 # |차액| > 1억원: 분개 거부 (안전 가드)
|
||||
@@ -124,6 +126,17 @@ def validate_envelope(env: dict) -> None:
|
||||
raise ValidationError('payload 가 dict 가 아님')
|
||||
|
||||
|
||||
def _validate_amount_block(block: dict, where: str) -> None:
|
||||
for key in ('deposit', 'eval_amount', 'total'):
|
||||
if key not in block:
|
||||
raise ValidationError(f'{where}.{key} 누락')
|
||||
v = block[key]
|
||||
if not isinstance(v, int) or v < 0:
|
||||
raise ValidationError(f'{where}.{key} 비정상: {v!r}')
|
||||
if v > PAYLOAD_AMOUNT_CAP:
|
||||
raise ValidationError(f'{where}.{key} 100억 초과 (가드): {v:,}')
|
||||
|
||||
|
||||
def validate_securities_payload(payload: dict) -> None:
|
||||
for key in ('as_of', 'accounts', 'totals', 'owner_scope'):
|
||||
if key not in payload:
|
||||
@@ -137,24 +150,28 @@ def validate_securities_payload(payload: dict) -> None:
|
||||
if as_of_dt.day not in (1, 10, 20):
|
||||
raise ValidationError(f'as_of 는 매월 1·10·20일이어야 함: {as_of}')
|
||||
|
||||
totals = payload['totals']
|
||||
for key in ('deposit', 'eval_amount', 'total'):
|
||||
if key not in totals:
|
||||
raise ValidationError(f'totals.{key} 누락')
|
||||
v = totals[key]
|
||||
if not isinstance(v, int) or v < 0:
|
||||
raise ValidationError(f'totals.{key} 비정상: {v!r}')
|
||||
if v > PAYLOAD_AMOUNT_CAP:
|
||||
raise ValidationError(f'totals.{key} 100억 초과 (가드): {v:,}')
|
||||
_validate_amount_block(payload['totals'], 'totals')
|
||||
|
||||
if not isinstance(payload['accounts'], list) or not payload['accounts']:
|
||||
raise ValidationError('accounts 비어있음')
|
||||
sum_total = sum(int(a.get('total', 0)) for a in payload['accounts'])
|
||||
if sum_total != totals['total']:
|
||||
if sum_total != payload['totals']['total']:
|
||||
raise ValidationError(
|
||||
f'accounts 합계 불일치: {sum_total:,} vs totals.total={totals["total"]:,}'
|
||||
f'accounts 합계 불일치: {sum_total:,} vs totals.total={payload["totals"]["total"]:,}'
|
||||
)
|
||||
|
||||
# v2: 소유자별 집계(by_owner) 검증. 미등록 소유자·구조 오류 차단.
|
||||
by_owner = payload.get('by_owner')
|
||||
if by_owner is not None:
|
||||
if not isinstance(by_owner, dict) or not by_owner:
|
||||
raise ValidationError('by_owner 비어있음')
|
||||
for owner, block in by_owner.items():
|
||||
if owner not in OWNER_ASSET:
|
||||
raise ValidationError(f'by_owner 미등록 소유자: {owner}')
|
||||
if not isinstance(block, dict):
|
||||
raise ValidationError(f'by_owner.{owner} 가 dict 가 아님')
|
||||
_validate_amount_block(block, f'by_owner.{owner}')
|
||||
|
||||
|
||||
def fetch_whooing_balance(as_of: str | None = None) -> dict:
|
||||
cmd = ['python3', str(BALANCE_SCRIPT), '--json']
|
||||
@@ -197,23 +214,25 @@ def post_whooing(payload: dict, dry_run: bool = False) -> tuple[bool, str]:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> dict:
|
||||
"""차액 reconcile + 후잉 자동 분개. 반환: {action, delta, before, after, journal_ok, note, journal?}.
|
||||
def _reconcile_asset(asset_name: str, owner: str, target_total: int,
|
||||
as_of: str, message_id: str, current_balance: dict,
|
||||
dry_run: bool = False) -> dict:
|
||||
"""자산 하나를 목표총액으로 reconcile + 후잉 자동 분개. 반환 result dict.
|
||||
action ∈ {aligned, journaled, noise, skipped, rejected, journal_failed}.
|
||||
"""
|
||||
payload = envelope['payload']
|
||||
target_total = int(payload['totals']['total'])
|
||||
current = get_whooing_balance_for(SECURITIES_ASSET_NAME, current_balance)
|
||||
current = get_whooing_balance_for(asset_name, current_balance)
|
||||
base = {
|
||||
'topic': 'securities_balance',
|
||||
'as_of': payload.get('as_of'),
|
||||
'owner': owner,
|
||||
'asset_name': asset_name,
|
||||
'as_of': as_of,
|
||||
'target_total': target_total,
|
||||
}
|
||||
if current is None:
|
||||
return {
|
||||
**base,
|
||||
'action': 'skipped',
|
||||
'note': f'후잉 자산에 "{SECURITIES_ASSET_NAME}" 항목 없음',
|
||||
'note': f'후잉 자산에 "{asset_name}" 항목 없음',
|
||||
'delta': None, 'before': None, 'after': None, 'journal_ok': None,
|
||||
}
|
||||
delta = target_total - current
|
||||
@@ -230,14 +249,14 @@ def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bo
|
||||
'before': current, 'after': current, 'journal_ok': False,
|
||||
'note': f'차액 {delta:+,}원 — 1억원 초과, 분개 거부 (가드)'}
|
||||
|
||||
entry_date = payload['as_of'].replace('-', '')
|
||||
ym = payload['as_of'][:7]
|
||||
entry_date = as_of.replace('-', '')
|
||||
ym = as_of[:7]
|
||||
if delta > 0:
|
||||
item = f'{ym} 평가차익'
|
||||
left, right, money = SECURITIES_ASSET_NAME, SECURITIES_GAIN_NAME, delta
|
||||
left, right, money = asset_name, SECURITIES_GAIN_NAME, delta
|
||||
else:
|
||||
item = f'{ym} 평가차손'
|
||||
left, right, money = SECURITIES_LOSS_NAME, SECURITIES_ASSET_NAME, -delta
|
||||
left, right, money = SECURITIES_LOSS_NAME, asset_name, -delta
|
||||
|
||||
journal = {
|
||||
'entry_date': entry_date,
|
||||
@@ -245,7 +264,7 @@ def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bo
|
||||
'money': str(money),
|
||||
'left': left,
|
||||
'right': right,
|
||||
'memo': f'레이 inbox reconcile (msg={envelope["message_id"][:8]})',
|
||||
'memo': f'레이 inbox reconcile (msg={message_id[:8]})',
|
||||
}
|
||||
ok, body = post_whooing(journal, dry_run=dry_run)
|
||||
return {
|
||||
@@ -260,6 +279,31 @@ def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bo
|
||||
}
|
||||
|
||||
|
||||
def handle_securities_balance(envelope: dict, current_balance: dict, dry_run: bool = False) -> list[dict]:
|
||||
"""소유자별 reconcile. v2(by_owner)는 소유자마다, v1은 totals.total→증권(효원) 단건.
|
||||
반환: result dict 리스트.
|
||||
"""
|
||||
payload = envelope['payload']
|
||||
as_of = payload['as_of']
|
||||
msg_id = envelope['message_id']
|
||||
by_owner = payload.get('by_owner')
|
||||
|
||||
if by_owner:
|
||||
results = []
|
||||
for owner, block in sorted(by_owner.items()): # 'gahee' < 'self' — 결정적 순서
|
||||
results.append(_reconcile_asset(
|
||||
OWNER_ASSET[owner], owner, int(block['total']),
|
||||
as_of, msg_id, current_balance, dry_run=dry_run,
|
||||
))
|
||||
return results
|
||||
|
||||
# v1 하위호환: 단일 소유자(self) → 증권(효원)
|
||||
return [_reconcile_asset(
|
||||
SECURITIES_ASSET_NAME, 'self', int(payload['totals']['total']),
|
||||
as_of, msg_id, current_balance, dry_run=dry_run,
|
||||
)]
|
||||
|
||||
|
||||
def move_to(src: Path, dest_dir: Path, dry_run: bool = False) -> None:
|
||||
if dry_run:
|
||||
return
|
||||
@@ -350,24 +394,28 @@ def process_inbox(current_balance: dict | None = None, dry_run: bool = False) ->
|
||||
validate_securities_payload(env['payload'])
|
||||
if balance is None:
|
||||
balance = fetch_whooing_balance()
|
||||
result = handle_securities_balance(env, balance, dry_run=dry_run)
|
||||
summary['reconcile'].setdefault(topic, []).append(result)
|
||||
results = handle_securities_balance(env, balance, dry_run=dry_run)
|
||||
summary['reconcile'].setdefault(topic, []).extend(results)
|
||||
|
||||
if result['action'] in ('rejected', 'journal_failed'):
|
||||
# 소유자 하나라도 rejected/journal_failed 면 failed 로.
|
||||
# 재실행 시 이미 분개된 소유자는 fresh 조회로 delta≈0 aligned → 중복분개 없음.
|
||||
bad = [r for r in results if r['action'] in ('rejected', 'journal_failed')]
|
||||
if bad:
|
||||
move_to(fpath, FAILED_DIR, dry_run=dry_run)
|
||||
reason = result['note']
|
||||
reason = '; '.join(f'{r["asset_name"]}: {r["note"]}' for r in bad)
|
||||
summary['failed'].append({'file': fpath.name, 'reason': reason, 'message_id': msg_id})
|
||||
send_telegram(f'⚠️ 골디 inbox: 분개 {result["action"]}\n{fpath.name}\n{reason}')
|
||||
send_telegram(f'⚠️ 골디 inbox: 분개 {"/".join(r["action"] for r in bad)}\n{fpath.name}\n{reason}')
|
||||
continue
|
||||
|
||||
# 분개 성공/aligned/noise/skipped 모두 processed 로 이동
|
||||
if result.get('journal_ok') and result['action'] == 'journaled' and balance is not None:
|
||||
# 후잉 잔액 in-memory 업데이트 (다음 파일 reconcile 시 정합성 위해)
|
||||
# 분개 성공분은 후잉 잔액 in-memory 업데이트 (동일 run 내 정합성 위해)
|
||||
if balance is not None:
|
||||
for r in results:
|
||||
if r.get('journal_ok') and r['action'] == 'journaled':
|
||||
for sec in balance.get('sections', []):
|
||||
items = (sec.get('groups', {}).get('자산', {}) or {}).get('items', []) or []
|
||||
for it in items:
|
||||
if it.get('name') == SECURITIES_ASSET_NAME:
|
||||
it['money'] = result['after']
|
||||
if it.get('name') == r['asset_name']:
|
||||
it['money'] = r['after']
|
||||
else:
|
||||
raise ValidationError(f'핸들러 없음: {topic}')
|
||||
|
||||
@@ -415,22 +463,23 @@ def format_summary(summary: dict) -> str:
|
||||
before = result.get('before')
|
||||
after = result.get('after')
|
||||
delta = result.get('delta')
|
||||
label = result.get('asset_name', '증권(효원)')
|
||||
if action == 'aligned':
|
||||
lines.append(f'- **증권(효원):** {before:,}원 — 레이 ground truth 와 일치')
|
||||
lines.append(f'- **{label}:** {before:,}원 — 레이 ground truth 와 일치')
|
||||
elif action == 'journaled':
|
||||
sign = '평가차익' if (delta or 0) > 0 else '평가차손'
|
||||
lines.append(
|
||||
f'- **증권(효원):** {before:,}원 → {after:,}원 '
|
||||
f'- **{label}:** {before:,}원 → {after:,}원 '
|
||||
f'({sign} **{abs(delta):,}원** 자동 분개)'
|
||||
)
|
||||
elif action == 'noise':
|
||||
lines.append(f'- **증권(효원):** 차액 {delta:+,}원 (1만원 미만, 분개 skip)')
|
||||
lines.append(f'- **{label}:** 차액 {delta:+,}원 (1만원 미만, 분개 skip)')
|
||||
elif action == 'skipped':
|
||||
lines.append(f'- **증권(효원):** {result.get("note", "skip")}')
|
||||
lines.append(f'- **{label}:** {result.get("note", "skip")}')
|
||||
elif action == 'rejected':
|
||||
lines.append(f'- **증권(효원):** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)')
|
||||
lines.append(f'- **{label}:** ⚠️ 차액 {delta:+,}원 — 분개 거부 (안전 가드)')
|
||||
elif action == 'journal_failed':
|
||||
lines.append(f'- **증권(효원):** ⚠️ 분개 실패 — {result.get("note", "")[:120]}')
|
||||
lines.append(f'- **{label}:** ⚠️ 분개 실패 — {result.get("note", "")[:120]}')
|
||||
|
||||
if summary['failed']:
|
||||
lines.append('')
|
||||
|
||||
@@ -238,19 +238,20 @@ def format_inbox_telegram_line(summary: dict) -> str:
|
||||
for r in summary.get("reconcile", {}).get("securities_balance", []):
|
||||
action = r.get("action")
|
||||
delta = r.get("delta") or 0
|
||||
label = r.get("asset_name", "증권(효원)")
|
||||
if action == "aligned":
|
||||
parts.append("증권 일치")
|
||||
parts.append(f"{label} 일치")
|
||||
elif action == "journaled":
|
||||
sign = "차익" if delta > 0 else "차손"
|
||||
parts.append(f"증권 평가{sign} {abs(delta):,}원 분개")
|
||||
parts.append(f"{label} 평가{sign} {abs(delta):,}원 분개")
|
||||
elif action == "noise":
|
||||
parts.append(f"증권 차액 {delta:+,}원 (노이즈)")
|
||||
parts.append(f"{label} 차액 {delta:+,}원 (노이즈)")
|
||||
elif action == "rejected":
|
||||
parts.append(f"⚠️ 증권 차액 {delta:+,}원 거부")
|
||||
parts.append(f"⚠️ {label} 차액 {delta:+,}원 거부")
|
||||
elif action == "journal_failed":
|
||||
parts.append("⚠️ 증권 분개 실패")
|
||||
parts.append(f"⚠️ {label} 분개 실패")
|
||||
elif action == "skipped":
|
||||
parts.append("증권 reconcile skip")
|
||||
parts.append(f"{label} reconcile skip")
|
||||
# rejected/journal_failed 는 위 reconcile 라인에서 이미 표시됨 — 중복 카운트 방지
|
||||
reconcile_failed = sum(
|
||||
1 for r in summary.get("reconcile", {}).get("securities_balance", [])
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: whooing-sync
|
||||
description: iMessage 카드·은행 결제 알림을 후잉(whooing.com) 가계부로 자동 동기화한다. 발신번호→계정 매핑(whooing_account_map.json), 결제 취소 감액 처리, 미매핑 발신처 보고, 수동 분개 등록(whooing_manual.py), 잔액 조회(whooing_balance.py)를 포함. "가계부 동기화", "후잉 동기화", "결제내역 정리", "이번달 지출 얼마야?" 같은 요청에 사용. 기본 운용은 launchd가 매시 0/15/30/45분 자동 실행.
|
||||
---
|
||||
|
||||
# whooing-sync
|
||||
|
||||
iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹훅으로 자동 전송한다.
|
||||
@@ -10,6 +15,18 @@ iMessage에 들어오는 카드/은행 결제 알림을 후잉(whooing.com) 웹
|
||||
|
||||
## How
|
||||
|
||||
**on-demand 실행은 launchd 잡을 앞당겨 돌린다.** 스크립트를 직접 실행하지 말 것 —
|
||||
에이전트 세션(코덱스)은 게이트웨이 node 를 TCC responsible process 로 물고 있어서
|
||||
`imsg` 가 Messages DB 를 못 읽는다 (아래 "Full Disk Access" 참고). launchd 잡은 그 제약이 없다.
|
||||
|
||||
```bash
|
||||
launchctl kickstart -p gui/$(id -u)/ai.openclaw.budget.whooing-sync
|
||||
# 비동기 — 몇 초 뒤 결과 확인
|
||||
tail -20 /Users/snowoyh/.openclaw/logs/whooing-sync.log
|
||||
```
|
||||
|
||||
스크립트 직접 실행은 **FDA 를 가진 터미널 앱**(iTerm2 등)에서만 동작한다:
|
||||
|
||||
```bash
|
||||
python3 /Users/snowoyh/.openclaw/agents/budget/workspace/skills/whooing-sync/scripts/whooing_sync.py
|
||||
```
|
||||
@@ -39,6 +56,24 @@ launchd/cron 이 이 줄을 그대로 결과로 받는다.
|
||||
- **실패 알림** — 후잉 웹훅이 거절(HTTP 에러, 본문 `fail` / `Error :`)한 건. 실패 1건당 1메시지, 4건 이상이면 앞 3건 + 초과분 요약. `_format_sync_failure()` 포맷.
|
||||
- **raw 폴백 알림** — 후잉은 200 받았지만 structured 매칭 실패로 raw 모드로 넘어간 건. sync 사이클당 **1메시지**(최대 3건 나열, 초과 시 카운트). parser / carrier_to_account / merchant_map 보완 신호. `_format_raw_fallback()` 포맷.
|
||||
|
||||
- **기초잔액(자본) 분개 보류 알림** (2026-09-07) — 분개의 좌·우 어느 쪽이든 `순자산` 계정(`기초잔액(효원)`/`기초잔액(가희)`)이면 **후잉에 넣지 않고 알림만** 보낸다(관리자님 지시). ⚠️ 2026-09-10 부터 **여기 오기 전에 증권 이체 매칭을 먼저 시도**한다(아래 「증권 이체 매칭」) — 짝을 찾으면 자본이 아니라 자산↔자산 이체로 분개되고, 이 알림은 짝을 못 찾은 건에만 남는다. `_format_capital_blocked()` 포맷, 기록은 `whooing_failures.json` 의 `capital_blocked` 키.
|
||||
- 배경: `merchant_map` 의 `방효원` exact 룰(`left: 기초잔액(효원)`, "본인 명의 자체이체")은 **짝이 되는 출금 SMS 가 같이 와서 상계되는 것**을 전제한다. 짝이 없으면 자본 한쪽만 남아 **순자산이 조용히 부풀려진다.** 2026-09-07 카카오뱅크 879,993 실사고 — 처제 대납금 상환인데 입금자명이 "방효원"이라 이 룰에 걸렸고, 금액이 08/27 클럽호핑과 정확히 일치해서 겨우 눈에 걸렸다(어중간한 금액이면 지나갔다).
|
||||
- ⚠️ **커서는 `latest_skip` 으로 진행시킨다** — 재시도해도 같은 룰이 같은 결과를 내므로 여기서 커서를 막으면 **이 메시지 뒤의 모든 거래가 영구히 멈춘다.** `blocked_at` 을 세우지 않는 이유.
|
||||
- 후잉엔 아무것도 안 들어가므로 그 계좌의 **잔액 불일치 알림이 뒤이어 뜬다** — 알림을 놓쳐도 두 번째 그물이 있다.
|
||||
- 분개는 관리자님이 직접: 진짜 자체이체면 **실제 출금 계좌**를 상대편으로, 아니면 성격에 맞는 계정으로.
|
||||
|
||||
- **은행이자 자동 분개 + 보류 알림** — 자산 계정에서 `diff < 0` 이고 `|diff| <= 5,000` 이면 차액 전액을 `계정 ← 기타수익 / 이자` 로 분개한다(카카오·신한·하나). 잔돈 단위 이자 SMS 가 안 오는 걸 메우는 장치.
|
||||
- ⚠️ **같은 사이클에 raw 폴백이 있던 계정은 분개하지 않고 알림만 보낸다** (2026-09-07 추가). 입금 SMS 는 structured 매칭에 실패해 raw 로 넘어가는데, 후잉은 200 만 주고 분개를 만들지 않는다. 그러면 diff 가 그 입금액만큼 음수가 되고, 이 heuristic 이 **그걸 이자로 오인해 가짜 수익을 원장에 굳혀버린다.**
|
||||
- 실사고: 2026-08-14 카카오뱅크 `+3,700원 은행이자` — 한재성 입금 10,900 미기록이 원인(기존 +7,200 드리프트와 상쇄돼 diff 가 −3,700). **진짜 8월 이자는 08/10 에 SMS 로 6,735원이 이미 들어와 있었다.** 한 달에 이자가 두 번 나올 리 없다는 게 판별점.
|
||||
- 판별 원칙: **진짜 은행이자는 SMS 로 온다**(06/10 1,711원·07/10 1,552원·08/10 6,735원). SMS 없이 자동분개된 건은 원래 1~300원대 잔돈이어야 정상이고, 네 자리 이상이면 의심한다.
|
||||
|
||||
- **잔액 불일치 알림** — SMS 잔액(자산)·누적사용액(카드)과 후잉 잔액이 어긋난 건. `_format_balance_mismatch()` 포맷. 차이가 직전과 같으면 재알림하지 않는다.
|
||||
- **누적 연속성 검사** (2026-09-07): 카드(부채) 계정에서 `diff < 0`(사용 거래 누락 의심)일 때만 `_cumulative_gap_scan()` 이 돌아 **어느 승인 사이에 얼마가 빠졌는지**까지 알림에 붙인다. 현대카드 SMS 의 `누적`은 승인마다 `직전 누적 + 승인액`이어야 하므로, 어긋난 구간이 곧 **문자가 안 오는 승인**(톨비 등)이다. 잔액 비교만으로는 "얼마 틀어졌다"까지만 알 수 있어 발생 시점을 못 짚었다.
|
||||
- ⚠️ **양의 격차만 센다** — 카드대금 상환·정산은 누적을 떨어뜨리는데 이걸 격차로 세면 오탐이다.
|
||||
- ⚠️ **이미 손으로 보정해 둔 건은 구간에서 차감한다** — 메모가 SMS 원문으로 파싱되지 않는 항목(`현대카드 잔액차 보정: 톨비` 등)을 앞선 구간부터 소진시킨다. 안 그러면 톨비를 넣어준 뒤에도 같은 구간이 영원히 미해결로 뜬다. 한 보정이 여러 구간에 중복 차감되지 않게 순서대로 소비한다.
|
||||
- ⚠️ **호출 조건을 넓히지 말 것** — 후잉 entries 조회가 들어가므로 평시(diff 0)엔 돌지 않는다.
|
||||
- 자동 분개는 하지 않는다. 톨비인지 다른 누락인지는 관리자님이 판단한다.
|
||||
|
||||
같은 SMS 1건이 두 알림에 동시에 걸리는 일은 없다 (`if ok / else` mutually exclusive). 한 사이클에서 서로 다른 SMS 들이 각각 raw · 실패로 나뉘면 두 메시지가 함께 간다.
|
||||
|
||||
`--dry-run` 시에는 둘 다 발송 안 함.
|
||||
@@ -72,18 +107,42 @@ launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.openclaw.budget.whooi
|
||||
|
||||
## 전제조건: Full Disk Access (FDA)
|
||||
|
||||
launchd 컨텍스트에서 `imsg` 가 `~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. 터미널에서 직접 돌릴 땐 터미널 앱의 FDA 를 자식 프로세스가 상속받지만, launchd 는 상속 안 됨.
|
||||
`imsg` 가 `~/Library/Messages/chat.db` 를 읽으려면 FDA 허용이 필요. **판정 주체는 TCC responsible process** — 자기 자신이 아니라 프로세스 체인의 책임 프로세스다.
|
||||
|
||||
- 등록 대상: `/opt/homebrew/bin/imsg`
|
||||
- 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
|
||||
- launchd → `/usr/bin/python3`(플랫폼 바이너리) → `imsg`: 책임이 `imsg` 자신이라 **`/opt/homebrew/bin/imsg` 승인으로 통과**
|
||||
- 터미널 앱 → … → `imsg`: 터미널 앱(iTerm2 등)의 FDA 로 통과
|
||||
- 게이트웨이 node → codex → bash → `imsg`: **게이트웨이 node 바이너리**의 FDA 로 판정됨
|
||||
|
||||
**FDA 누락 증상**: stderr 로그에
|
||||
등록 경로: 시스템 설정 → 개인정보 보호 및 보안 → 전체 디스크 접근 권한 → `+` 로 추가 → 토글 ON
|
||||
|
||||
**FDA 누락 증상**: `imsg` 가 stderr 로 다음을 낸다.
|
||||
|
||||
```
|
||||
❌ imsg chats 실행 실패: Expecting value: line 1 column 1 (char 0)
|
||||
Permission Error: Cannot access Messages database
|
||||
... requires Full Disk Access permission.
|
||||
```
|
||||
|
||||
가 뜨고, stdout 은 `🟢 새 결제 메시지 없음` 으로 조용히 끝남. 오류처럼 보이지 않아 놓치기 쉬움. 맥 이전 / imsg 재설치 시 FDA 재등록 필요.
|
||||
`imsg_cli.run_json()` 이 이걸 잡아 로그에 그대로 옮긴다:
|
||||
|
||||
```
|
||||
⚠️ chat-id 7 history 실패: Messages DB 접근 거부 — 전체 디스크 접근 권한(FDA) 누락. ...
|
||||
```
|
||||
|
||||
### 2026-08-03 사고: homebrew node 업그레이드로 FDA 무효화
|
||||
|
||||
node@22 가 2026-07-20 에 `22.22.2` → `22.23.1` 로 올라가며 **바이너리 경로가 바뀌어** 기존 FDA 승인이 무효화됐다 (시스템 TCC.db 에 22.22.2=허용 / 22.23.1=거부로 남아 있음). 게이트웨이가 7/21 새 바이너리로 재기동되면서 골디의 에이전트 세션에서 `imsg` 가 죽었다. **launchd 잡은 영향 없었다** — 그래서 자동 동기화는 정상인데 골디만 실패하는 그림이 됐고, 골디는 이걸 "문자 수집 실패"로 오진했다.
|
||||
|
||||
- 재발 시 확인: `lsof -p $(pgrep -f 'openclaw/dist/index.js gateway') -a -d txt | head -1` 로 실제 node 경로를 뽑아, 그 경로를 FDA 목록에 추가
|
||||
- 그 다음 게이트웨이 재기동: `launchctl kickstart -k gui/$(id -u)/ai.openclaw.gateway`
|
||||
- **on-demand 실행을 kickstart 로 바꾼 이유가 이것** — node 업그레이드마다 깨지는 의존을 실행 경로에서 뺐다
|
||||
|
||||
당시 오진을 만든 코드 결함 3개는 함께 고쳤다 (2026-08-03):
|
||||
|
||||
1. 호출부가 stderr·returncode 를 버려 권한 오류가 `Expecting value: line 1 column 1 (char 0)` 파싱 에러로 둔갑 → `imsg_cli.py` 공용 래퍼로 사유 노출
|
||||
2. `resolve_chat_ids()` 가 권한 거부 시 **빈 chat id 매핑을 캐시에 저장** → 이후 launchd 포함 전 실행이 캐시 적중으로 조용히 0건 수집을 영구 반복. 이제 전 carrier 가 비면 저장 거부 + exit 3
|
||||
3. `imsg chats` 기본값이 최근 20건이라 조용한 발신번호(현대카드 SMS·신한은행)가 목록에서 빠짐 → 캐시 재생성 시 그 carrier 수집이 조용히 끊김. `CHATS_LIMIT=200` 으로 전량 조회
|
||||
|
||||
맥 이전 / imsg 재설치 시에도 FDA 재등록 필요.
|
||||
|
||||
## 페어 매칭 (자기 계좌 간 이체)
|
||||
|
||||
@@ -104,6 +163,30 @@ launchd 컨텍스트에서 `imsg` 가 `~/Library/Messages/chat.db` 를 읽으려
|
||||
- 한쪽 은행이 confirmed=false → SMS 수집 안 됨. 메모 키워드("카뱅오픈방효원")가 `merchant_map.contains` 와 맞으면 withdrawal-only structured 로 처리. 아니면 raw.
|
||||
- 시간창 벗어남 / 단독 입출금 / 외부 송금 → 각자 개별 처리.
|
||||
|
||||
## 증권 이체 매칭 (은행 SMS ↔ 키움 kt00015)
|
||||
|
||||
**은행에서 증권으로 이체하면 은행 SMS 에는 입금자명(=본인 이름)만 찍혀 목적지를 알 수 없다.** 그래서 `방효원` exact 룰에 걸려 `기초잔액 ← 은행` 이 되고, 위 자본 분개 가드에 막혀 **미반영**된다. 반대편(증권 입금)은 **SMS 가 아예 없어** 월 1회 `securities_balance` reconcile 의 차액으로만 나타나는데, 그 차액은 전부 `주식평가수익` 으로 분개되므로 **이체가 손익으로 둔갑**한다. 양쪽에서 동시에 새던 구멍이다.
|
||||
|
||||
2026-09-10 부터 그 짝을 **키움 kt00015 입출금 내역에서 찾아** `증권(효원) ← 하나은행(효원)` 이체 한 건으로 만든다. 모듈 `scripts/securities_match.py`, 진입점은 `whooing_sync.try_securities_transfer()`.
|
||||
|
||||
- **호출 시점은 capital-block 직전뿐** — 평시 키움 콜 0. 자본 분개로 갈 뻔한 건이 있는 사이클에만 계좌 수만큼(4콜) 조회한다. 그래서 15분 주기에 상시 조회가 붙지 않는다.
|
||||
- **매칭 조건**: 같은 날짜 + 금액 일치 + 방향 반대(은행 출금↔증권 IN) + `investment_principal.is_principal_flow(적요)`. 적요 필터가 **배당·이자·수익분배금·공모주환불금을 걸러낸다** — 그것들은 이체가 아니라 수익이라 은행 SMS 와 짝이 될 수 없다.
|
||||
- ⚠️ **애매하면 매칭하지 않는다** — 후보가 여럿이면 시각 근접(`TIME_TIEBREAK_SECONDS` 600초)으로 좁히고, 그래도 하나로 안 줄면 포기하고 기존 알림 경로로 보낸다. 틀린 분개보다 사람이 보는 편이 낫다. 조회가 한 계좌라도 실패하면 **전체를 포기**한다(일부만 보고 판단하면 오매칭).
|
||||
- ⚠️ **증권 계좌 간 대체는 후보에서 뺀다**(`_drop_internal_transfers`) — 가희_일반→가희_ISA 같은 내부 이동은 후잉에선 같은 `증권(가희)` 자산이라 분개 대상이 아닌데, 금액이 같으면 은행 SMS 와 오매칭될 수 있다. 서로 다른 계좌에 같은 금액의 IN/OUT 이 함께 있으면 제외(2026-04-23 가희 100만원 실사례로 검증).
|
||||
- ⚠️ **kt00015 행에는 고유 ID 가 없다** — 중복 분개 방지 키는 `날짜|계좌|IN,OUT|금액|시각` 합성이고 `state/whooing_securities_matched.json` 에 남는다(180일 후 정리). **POST 성공 후에만 기록**한다 — 실패하면 다음 사이클에 다시 짝으로 잡혀야 한다.
|
||||
- 양방향 지원: 은행 출금→`증권 ← 은행`, 은행 입금(증권에서 뺀 돈)→`은행 ← 증권`.
|
||||
- 부수효과: 이체가 실시간으로 들어가므로 **월 1회 reconcile 차액에 순수 평가손익만 남는다.**
|
||||
|
||||
출력 예시:
|
||||
```
|
||||
✅ [securities] 하나 2026-09-10T00:27:44Z 200 | 증권(효원) ← 하나은행(효원) 3,000,000원 (증권 일반 이체)
|
||||
```
|
||||
|
||||
진단 CLI (매칭만 확인, 분개 없음):
|
||||
```bash
|
||||
python3 scripts/securities_match.py 20260910 3000000 withdrawal 2026-09-10T00:27:44.111Z
|
||||
```
|
||||
|
||||
### merchant_map 주의사항
|
||||
|
||||
`state/whooing_merchant_map.json` exact 룰에 `"방효원": { "left": "기초잔액(효원)" }` 가 등록돼 있다. 페어 미성립 시 이 룰로 폴백해서 **기초잔액을 경유한 잘못된 분개**가 기록될 수 있다. 한쪽 carrier 가 confirmed=false 일 때 구멍이 크다.
|
||||
@@ -118,7 +201,18 @@ launchd 컨텍스트에서 `imsg` 가 `~/Library/Messages/chat.db` 를 읽으려
|
||||
|
||||
- 사람 이름 송금(예: "박영춘", "이지윤")은 exact 룰로 등록하지 말고 default fallback 에 맡긴다. merchant_map 비대화 방지.
|
||||
- `deposit` 은 default fallback 없음 — rule 없으면 raw 폴백 (수익/이체/환급 구분 위험 때문).
|
||||
- ⚠️ **후잉은 계정 그룹별로 쓸 수 있는 변이 정해져 있다** — `비용(expenses)`은 **차변(left) 전용**, `수익(income)`은 **대변(right) 전용**이고 `자산·부채`만 양쪽 자유다. 위반하면 `[r_account] => 잘못된 값입니다`(비용을 우측에) 또는 `[l_account] => 잘못된 값입니다`(수익을 좌측에)로 **HTTP 200 + 실패 본문**이 온다. 이 제약을 몰라서 2026-08-03~04 회귀가 났다. 새 분개 방향을 만들 때 반드시 확인할 것.
|
||||
- ⚠️ **후잉은 취소·환불 문자를 자동 처리하지 않는다.** 공식 FAQ(`/help/faqs/inserting/refund`)가 "기존의 구입 거래를 **찾아서 지워주거나**, 금액을 마이너스로..." 라고 **사용자에게 수동 안내**한다. raw 로 취소 원문을 보내도 원본 승인은 그대로 남는다. 2026-08-04 에 과거 3건(07-04 카카오 월드킹·06-09 현대 쿠팡·06-13 현대 네이버페이)에서 원본이 사라진 걸 보고 "후잉 파서가 지운다"고 판단했는데, **관리자님이 그동안 수동 삭제해 오신 것**이었다. 두 설명 모두 "건수 −1, 순액 일치"라 검산으로는 구분되지 않는다 — 같은 착각을 반복하지 말 것.
|
||||
- `card_cancel` 은 **원본 승인 거래를 찾아 PUT 으로 감액**한다 (2026-08-04, 관리자님 결정 — 수동 삭제를 없애는 게 목적). `_handle_card_cancel` → `whooing_balance.find_card_entries` 로 같은 카드·같은 상호(memo 토큰 매칭)·금액이 취소액 이상인 거래를 30일 창에서 찾고, **정확히 1건일 때만** `set_entry_money` 로 `원금액 − 취소액` 으로 줄인다. 전액취소면 0원 줄이 남지만 잔액·합계에 영향은 없다.
|
||||
- ⚠️ **좌우를 뒤집은 역분개로 바꾸지 말 것** — 비용이 대변으로 가서 후잉이 거부한다(위 제약). 2026-08-03 에 이 방식으로 바꿨다가 **첫 취소 문자에서 동기화가 통째로 멈췄다**(`blocked_at` 이 커서를 잠가 뒤의 정상 결제까지 전부 대기).
|
||||
- ⚠️ **거래 삭제는 API 로 불가능하다** — 후잉 OpenAPI 의 DELETE 는 `section_id` 를 쿼리·본문(urlencoded/JSON)·쿠키·경로 어디에서도 읽지 못한다(2026-08-04 전수 실측, 같은 본문을 PUT 으로 보내면 정상). 0원 줄을 지우려면 후잉 UI 에서 수동으로. 마이너스 분개(후잉 공식 안내안)도 가능하지만 ± 두 줄이 남아 채택하지 않았다.
|
||||
- **후보가 0건이거나 2건 이상이면 감액하지 않고 raw 폴백**(오감액보다 안전). 이때는 **후잉이 알아서 처리해주지 않으므로 관리자님 수동 작업이 필요하다** — 그래서 폴백 건은 `new_raw` 에 그대로 실어 raw 폴백 텔레그램 알림이 울리게 둔다. 알림을 끄면 수동 작업이 필요한 걸 모르고 지나친다.
|
||||
- ⚠️ **매칭은 `item` 이 아니라 `memo` 로 한다** — `merchant_map` contains 룰이 `item` 을 재작성해서(동행복권→`복권`) item 은 상호와 다를 수 있지만 memo 는 항상 승인 SMS 원문이다.
|
||||
- ⚠️ **memo 매칭은 토큰 단위**(`_memo_has_merchant`) — 단순 substring 이면 `쿠팡` 이 `쿠팡페이주` 승인까지 잡는다. 2026-08-04 에 두 상호가 같은 날 같은 금액(22,700원)으로 실제로 있었다.
|
||||
- ⚠️ **`card_cancel` 을 내는 파서는 현대카드·신한카드(`매입취소`/`승인취소`) 둘뿐이다.** 카카오뱅크 `카드결제취소` 는 `KAKAOBANK_CARD_RE` 의 `카드결제\s+` 가 안 맞아 **파싱 자체가 실패**(`kind=None`)하고, 하나·신한은행은 입금/출금만 있다. **이들은 감액 대상이 아니라 raw 로 가고, 후잉도 처리하지 않으므로 관리자님 수동 삭제가 계속 필요하다.** 자동화하려면 `parsers.py` 보강 + 2026-08-03 픽스처 회귀 재실행이 필요하다 — **2026-08-04 관리자님 지시로 "실제 취소 문자가 올 때 처리"로 보류**. 판단 근거는 파싱이 실패해도 `_infer_raw_details` 가 정규식으로 금액·상호를 뽑아 raw 폴백 알림에 실어주기 때문(실측: 카카오 취소 문자 → `2,000원 · (주)월드킹`). 알림을 보고 그때 대응하면 된다.
|
||||
- **삼성카드는 아예 동기화 대상이 아니다** — 발신번호 `+8215888700` 이 `whooing_account_map.json` 에서 `confirmed=False` 라 수집조차 안 되고, 실제 오는 문자는 전부 `(광고)` 이며 후잉 `삼성신용(효원)`(x77) 은 최근 90일 거래 0건이다(2026-08-04 확인). 취소를 기다릴 대상이 아니라, **다시 사용하기 시작하면 승인 문자부터 누락**되니 그때 `confirmed` 등록이 먼저다.
|
||||
- 기존 contains(예: "스타벅스 → 식비") / exact(예: "방효원 → 기초잔액(효원)") 는 계속 유효. fallback 은 둘 다 miss 일 때만 탄다.
|
||||
- ⚠️ **주유소는 두 곳을 함께 손대야 한다** — `whooing_sync.py` 의 `FUEL_KEYWORDS`(150,000원 선승인/취소 스킵)와 `whooing_merchant_map.json` contains 룰(실제 결제 → `차량유지비/주유비`)이 한 쌍이다. 한쪽만 넣으면 선승인이 후잉에 남거나 실제 주유가 기타비용으로 샌다. 상호에 주유/석유가 없는 충전소(현대가스·한경에너)는 상호 자체를 등록. 한경에너는 선승인이 149,900원이라 `FUEL_PREAUTH_AMOUNT`(150,000 정확일치) 스킵엔 안 걸리고 분류만 적용된다.
|
||||
- 결과적으로 자잘한 인명 송금·가맹점 미등록 건은 전부 기타비용으로 자동 분류되고, 분류가 필요한 것만 후잉 UI 에서 사후 조정하거나 merchant_map 에 규칙 추가한다.
|
||||
|
||||
### 우선 룰 (whooing_overrides.json)
|
||||
@@ -229,7 +323,7 @@ python3 .../whooing_manual.py --dry-run --item ... --money ...
|
||||
원칙:
|
||||
- 차트에 없는 계정명으로 절대 추측 POST 금지. 모르면 반드시 재질문.
|
||||
- 사용자가 이미 한 문장에 "스타벅스 5800원 신한카드로" 다 말했다면, 카테고리만 확인하고 바로 실행.
|
||||
- 카드 취소/환불이면 structured 대신 `--message` raw 모드로 원문 보내 후잉이 상쇄하게 한다.
|
||||
- 카드 취소/환불은 원본 승인 거래를 찾아 감액하는 게 원칙(위 `card_cancel` 항목). 후잉은 취소 문자를 자동 처리해주지 않으니 raw 로 보내고 끝내면 안 된다 — 원본이 그대로 남는다.
|
||||
|
||||
## 잔액 조회 (whooing_balance.py)
|
||||
|
||||
@@ -296,6 +390,9 @@ python3 .../whooing_balance.py --json \
|
||||
### 흐름
|
||||
|
||||
1. **발신 게이트** — KST 기준 `day >= send_day_of_month` (catch-up 정책) 이고 시각이 `send_hour_kst` 이상이며 `last_sent_month` ≠ 이번 달이면 1회 발신. 25일에 Mac이 꺼져 있었어도 26~월말 사이 켜면 그 시점에 발신. 다음 달로 넘어가면 포기.
|
||||
- 발신은 **`--service imessage`** (2026-08-03 전환, 아래 사고 참고). 가희님은 iMessage 사용자다.
|
||||
- 성공 시 `last_sent_month` 와 함께 **`last_sent_at`** 을 찍는다 — 답신 미도착 감시의 시작점.
|
||||
1-b. **답신 미도착 감시** (`_check_reply_overdue`) — 발신 후 `REPLY_GRACE_DAYS`(2일)가 지나도 가희님 답신이 없으면 관리자님께 텔레그램 1회. 유예 전이면 API 콜 0으로 즉시 반환하고, 답신이 확인되면 `last_sent_at` 을 지워 감시를 끝낸다. 같은 발신 건에 대한 재알림은 `reply_alert_for` 로 막는다. ⚠️ **재발신은 자동으로 하지 않는다** — 가희님이 늦게 답하는 경우 중복 독촉이 되고, 자동 발송 트리거를 늘리지 않는다는 방침에도 어긋난다.
|
||||
2. **응답 폴링** — `imsg chats --json` → 가희 chat 찾기 → `imsg history --chat-id X --start <last_processed_message_at> --attachments --json`. 가희 발신(is_from_me≠true) 메시지만 처리.
|
||||
3. **1차 스캔** (즉시 워터마크 갱신):
|
||||
- **이미지 첨부 있음** → 자동분개 X, 골디 텔레그램 알림만 ("이미지 답신 — 직접 처리 부탁")
|
||||
@@ -322,6 +419,8 @@ python3 .../whooing_balance.py --json \
|
||||
"message_template": "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다.",
|
||||
"last_sent_month": null,
|
||||
"last_processed_message_at": null,
|
||||
"last_sent_at": null,
|
||||
"reply_alert_for": null,
|
||||
"whooing_account_name": "가희주머니",
|
||||
"income_account": "가희비밀주머니_수익",
|
||||
"expense_account": "기타비용"
|
||||
@@ -330,8 +429,29 @@ python3 .../whooing_balance.py --json \
|
||||
|
||||
- `last_sent_month` = "YYYY-MM" 발신 직후 기록. 같은 달에 재발신 안 함.
|
||||
- `last_processed_message_at` = imsg history `--start` 의 ISO 워터마크. 처리한 메시지의 created_at 최댓값.
|
||||
- `last_sent_at` = 발신 시각(UTC Z). 답신 미도착 감시의 기준점. 답신 확인되면 지워진다.
|
||||
- `reply_alert_for` = 이미 "답신 없음" 알림을 보낸 `last_sent_at` 값. 같은 발신 건 재알림 방지.
|
||||
- 발신 문구·트리거 일자·계정명 변경은 모두 이 JSON 직접 편집. 코드 재배포 불필요. whooing-sync 가 매 사이클 다시 읽음.
|
||||
|
||||
### 수동 발신 (`--send-now`)
|
||||
|
||||
발송이 실패해 그 달을 놓쳤을 때의 복구 수단. 날짜 게이트를 무시하고 1회 발신하며 `last_sent_month` 는 건드리지 않아 정규 25일 발신이 살아있다.
|
||||
|
||||
```bash
|
||||
python3 gahee_reminder.py --send-now [--dry-run]
|
||||
```
|
||||
|
||||
⚠️ **에이전트 세션·코디 셸에서 실행하면 20초 타임아웃으로 실패한다.** `imsg send` 는 AppleEvents 로 Messages 를 구동하는데, **자동화 → 메시지** 권한이 `imsg` 와 iTerm2 에만 허용돼 있고 Claude Code 에는 거부로 박혀 있다. launchd 경유로 돌려야 한다 — 임시 oneshot plist 를 만들어 `kickstart` 하고 로그를 읽은 뒤 `bootout` + 삭제한다(2026-08-03 실행 방식).
|
||||
|
||||
### 2026-07-25 사고: 미발송이 성공으로 기록됨
|
||||
|
||||
7/25 리마인더가 실제로 발송되지 않았는데(`chat.db`: `service=SMS, is_sent=0, error=4`) 시스템은 성공으로 기록했다. 가희주머니 7월 잔액이 안 들어와 8/1 월간 결산에서 빠졌다.
|
||||
|
||||
- **왜 실패했나** — `--service sms` 강제가 이 수신자에게 **3전 3패**였다(5/25 ×2, 7/25 ×1 전부 `error=4`). 살아남은 2건(5/25·6/25)은 Messages 가 임의로 **RCS 로 바꿔** 보낸 운이었고, 7월엔 그 폴백이 안 일어났다. 전체 통계로도 iMessage 137/139 성공 vs SMS 실패 3건(전부 이 리마인더).
|
||||
- **왜 몰랐나** — `imsg send` 는 Messages 에 넘기기만 성공하면 rc=0 이고, 실제 실패는 그 뒤 비동기로 `chat.db` 의 `error` 에 찍힌다. 코드가 rc 만 봐서 성공 처리 → 오탐 텔레그램 → `last_sent_month` 갱신 → 그 달 재시도 차단 → 8월엔 catch-up 이 당월만 보므로 7월분 영구 유실.
|
||||
- **왜 발송 검증을 자동 경로에 안 넣었나** — `is_sent`/`error` 는 `chat.db` 에만 있고 `imsg history` JSON 은 노출하지 않는다(실패한 메시지도 정상 조회됨). 직접 sqlite 조회가 유일한 길인데 **launchd 의 `/usr/bin/python3` 는 FDA 가 없어 `authorization denied` 로 막힌다**(2026-08-03 실측). 그래서 자동 경로는 권한과 무관한 답신 기반 감시를 쓰고, 정밀 확인(`_verify_sent`)은 FDA 가 있는 컨텍스트에서 도는 `--send-now` 에만 붙였다.
|
||||
- `error=4` 의 정확한 의미는 확증 못 함 — 사고 시점 imagent 로그가 이미 롤오프됐다.
|
||||
|
||||
### 수신자 설정
|
||||
|
||||
`/Users/snowoyh/.openclaw/credentials/gahee_imessage.json`:
|
||||
|
||||
@@ -12,13 +12,18 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import imsg_cli
|
||||
import notify
|
||||
import whooing_balance
|
||||
|
||||
@@ -27,6 +32,13 @@ GAHEE_CRED = Path("/Users/snowoyh/.openclaw/credentials/gahee_imessage.json")
|
||||
STATE_FILE = Path("/Users/snowoyh/.openclaw/agents/budget/workspace/state/gahee_reminder.json")
|
||||
IMSG_TIMEOUT = 20
|
||||
HISTORY_LIMIT = 50
|
||||
# 발신 후 이 기간 안에 가희님 답신이 없으면 발송 실패를 의심하고 관리자님께 1회 알린다.
|
||||
# imsg send 는 Messages 에 넘기기만 성공하면 rc=0 이라 발송 실패를 동기적으로 못 잡는다
|
||||
# (2026-07-25 사고: SMS error=4 로 미발송인데 성공 처리돼 그 달 재시도가 막혔다).
|
||||
# 실측 답신 지연은 3시간·38시간 — 2일이면 오탐이 적다.
|
||||
REPLY_GRACE_DAYS = 2
|
||||
CHAT_DB = Path.home() / "Library" / "Messages" / "chat.db"
|
||||
APPLE_EPOCH = 978307200 # chat.db date 는 2001-01-01 기준 ns
|
||||
# 라벨 + 금액 패턴. 라벨은 한글/영문/괄호/공백/숫자 1~20자.
|
||||
# 콜론 구분 입력은 정수 전체를 허용하고, 공백 구분 입력은 천 단위 콤마가 있을 때만 허용한다.
|
||||
# 일반 안내 문자의 "오전 09" 같은 표현을 잔액으로 오인하지 않기 위함.
|
||||
@@ -77,15 +89,11 @@ def _normalize(handle: str) -> str:
|
||||
|
||||
|
||||
def _imsg_chats() -> list[dict]:
|
||||
try:
|
||||
raw = subprocess.run(
|
||||
["imsg", "chats", "--json"],
|
||||
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
|
||||
)
|
||||
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
|
||||
except Exception as e:
|
||||
print(f"⚠️ 가희 imsg chats 실패: {e}")
|
||||
rows, err = imsg_cli.chats(IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"⚠️ 가희 imsg chats 실패: {err}")
|
||||
return []
|
||||
return rows
|
||||
|
||||
|
||||
def _find_gahee_chat_id(handle_norm: str) -> int | None:
|
||||
@@ -97,26 +105,35 @@ def _find_gahee_chat_id(handle_norm: str) -> int | None:
|
||||
|
||||
|
||||
def _imsg_history(chat_id: int, start_iso: str | None) -> list[dict]:
|
||||
cmd = ["imsg", "history", "--chat-id", str(chat_id),
|
||||
args = ["history", "--chat-id", str(chat_id),
|
||||
"--limit", str(HISTORY_LIMIT), "--attachments", "--json"]
|
||||
if start_iso:
|
||||
cmd += ["--start", start_iso]
|
||||
try:
|
||||
raw = subprocess.run(cmd, capture_output=True, text=True, timeout=IMSG_TIMEOUT)
|
||||
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
|
||||
except Exception as e:
|
||||
print(f"⚠️ 가희 imsg history 실패: {e}")
|
||||
args += ["--start", start_iso]
|
||||
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"⚠️ 가희 imsg history 실패: {err}")
|
||||
return []
|
||||
return rows
|
||||
|
||||
|
||||
def _send_imessage(handle: str, text: str, dry_run: bool) -> bool:
|
||||
"""가희님께 리마인더 발신.
|
||||
|
||||
⚠️ rc=0 은 "Messages 에 넘겼다"는 뜻일 뿐 발송 성공이 아니다. 실제 실패는 그 뒤
|
||||
비동기로 chat.db 의 error 칼럼에 찍힌다 — 여기서는 알 수 없다. 발송 여부 확인은
|
||||
_verify_sent()(FDA 필요, CLI 전용) 또는 _check_reply_overdue()(답신 기반)가 맡는다.
|
||||
|
||||
⚠️ service 는 imessage 다. sms 강제는 이 수신자에게 3전 3패였고(5/25 ×2, 7/25 ×1
|
||||
전부 error=4), 살아남은 2건은 Messages 가 임의로 RCS 로 바꿔준 운이었다. 가희님은
|
||||
iMessage 사용자다(답신 2건 모두 iMessage). 2026-08-03 전환.
|
||||
"""
|
||||
if dry_run:
|
||||
print(f"[dry-run] imsg send --to {handle} --text {text!r} --service sms")
|
||||
print(f"[dry-run] imsg send --to {handle} --text {text!r} --service imessage")
|
||||
return True
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["imsg", "send", "--to", handle, "--text", text,
|
||||
"--service", "sms"],
|
||||
"--service", "imessage"],
|
||||
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
@@ -128,6 +145,46 @@ def _send_imessage(handle: str, text: str, dry_run: bool) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _utc_z(dt: datetime) -> str:
|
||||
"""imsg --start / state 워터마크와 같은 UTC Z 포맷."""
|
||||
return dt.astimezone(ZoneInfo("UTC")).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _verify_sent(handle: str, after_unix: float, timeout: int = 20):
|
||||
"""chat.db 직접 조회로 실제 발송 여부 확인. 반환 (verdict, detail).
|
||||
|
||||
verdict True=발송됨 / False=실패 / None=확인 불가.
|
||||
chat.db 는 FDA 를 가진 컨텍스트에서만 읽힌다 — launchd(/usr/bin/python3)에선 못 읽으므로
|
||||
확인 불가(None)로 떨어진다. 이 함수는 CLI 전용이고, 자동 경로는 답신 기반 감시를 쓴다.
|
||||
"""
|
||||
digits = re.sub(r"\D", "", handle)[-8:]
|
||||
after_ns = int((after_unix - APPLE_EPOCH) * 1_000_000_000)
|
||||
deadline = time.time() + timeout
|
||||
detail = None
|
||||
while True:
|
||||
try:
|
||||
con = sqlite3.connect(f"file:{CHAT_DB}?mode=ro", uri=True)
|
||||
row = con.execute(
|
||||
"select m.service, m.is_sent, m.error from message m "
|
||||
"join handle h on m.handle_id = h.rowid "
|
||||
"where h.id like ? and m.is_from_me = 1 and m.date >= ? "
|
||||
"order by m.date desc limit 1",
|
||||
(f"%{digits}", after_ns),
|
||||
).fetchone()
|
||||
con.close()
|
||||
except Exception as e:
|
||||
return None, f"chat.db 조회 불가 ({type(e).__name__}: {e})"
|
||||
if row:
|
||||
detail = f"service={row[0]} is_sent={row[1]} error={row[2]}"
|
||||
if row[2]:
|
||||
return False, detail
|
||||
if row[1]:
|
||||
return True, detail
|
||||
if time.time() >= deadline:
|
||||
return None, detail or "발신 기록이 아직 안 보임"
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def _parse_balance_text(text: str) -> dict[str, int]:
|
||||
"""가희 답신 텍스트에서 '라벨 : 금액' 페어 추출. 빈 dict면 포맷 오류.
|
||||
|
||||
@@ -234,6 +291,9 @@ def _maybe_send_reminder(state: dict, dry_run: bool) -> bool:
|
||||
|
||||
if not dry_run:
|
||||
state["last_sent_month"] = month_tag
|
||||
# 답신 감시 시작점. 발송 성공 여부는 여기서 알 수 없으므로 답신으로 확인한다.
|
||||
state["last_sent_at"] = _utc_z(now)
|
||||
state.pop("reply_alert_for", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
notify.send(
|
||||
f"📨 <b>가희 잔액 리마인더 발신</b>\n"
|
||||
@@ -243,6 +303,51 @@ def _maybe_send_reminder(state: dict, dry_run: bool) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def _check_reply_overdue(state: dict, dry_run: bool):
|
||||
"""발신 후 REPLY_GRACE_DAYS 가 지나도 답신이 없으면 관리자님께 1회 알린다.
|
||||
|
||||
발송 실패를 직접 감지할 수 없어서(위 _send_imessage 주석) 결과로 대신 본다.
|
||||
2026-07-25 처럼 미발송이 조용히 넘어가는 걸 막는 유일한 안전망이다.
|
||||
|
||||
⚠️ 재발신은 자동으로 하지 않는다 — 가희님이 늦게 답하는 경우 중복 독촉이 되고,
|
||||
자동 발송 트리거를 늘리지 않는다는 방침에도 어긋난다. 알림 받고 --send-now 로 수동.
|
||||
"""
|
||||
sent_at = state.get("last_sent_at")
|
||||
if not sent_at:
|
||||
return
|
||||
try:
|
||||
sent_dt = datetime.fromisoformat(sent_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return
|
||||
if _now_kst() - sent_dt < timedelta(days=REPLY_GRACE_DAYS):
|
||||
return
|
||||
if state.get("reply_alert_for") == sent_at:
|
||||
return # 이번 발신 건은 이미 알림
|
||||
|
||||
handle_norm = _normalize(_gahee_handle())
|
||||
chat_id = _find_gahee_chat_id(handle_norm)
|
||||
if chat_id is None:
|
||||
return
|
||||
if any(_msg_from_gahee(m) for m in _imsg_history(chat_id, sent_at)):
|
||||
# 답신 도착 — 감시 종료
|
||||
if not dry_run:
|
||||
state.pop("last_sent_at", None)
|
||||
state.pop("reply_alert_for", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
return
|
||||
|
||||
days = (_now_kst() - sent_dt).days
|
||||
notify.send(
|
||||
f"⚠️ <b>가희 잔액 리마인더 — {days}일째 답신 없음</b>\n"
|
||||
f"발신: {notify.escape_html(sent_at)}\n"
|
||||
f"발송 자체가 실패했을 수 있어요 (imsg 는 발송 실패를 알려주지 않아요).\n"
|
||||
f"재발신: <code>python3 gahee_reminder.py --send-now</code>"
|
||||
)
|
||||
if not dry_run:
|
||||
state["reply_alert_for"] = sent_at
|
||||
_save_json(STATE_FILE, state)
|
||||
|
||||
|
||||
def _msg_text(msg: dict) -> str:
|
||||
"""imsg history 메시지 객체에서 본문 텍스트 추출. 키 변형(text/body) 모두 시도."""
|
||||
for k in ("text", "body", "message", "content"):
|
||||
@@ -402,9 +507,61 @@ def run(webhook_url: str, post_fn, dry_run: bool = False):
|
||||
_maybe_send_reminder(state, dry_run=dry_run)
|
||||
# 발신 직후 같은 사이클에서 폴링은 무의미하지만, 다음 사이클부터 자연스럽게 시작됨.
|
||||
_poll_replies(state, webhook_url, post_fn, dry_run=dry_run)
|
||||
# 발송 실패가 조용히 넘어가지 않게 하는 안전망. 유예 전이면 API 콜 없이 즉시 반환.
|
||||
_check_reply_overdue(state, dry_run=dry_run)
|
||||
except Exception as e:
|
||||
print(f"❌ gahee_reminder.run 예외 (격리됨): {e}")
|
||||
try:
|
||||
notify.send(f"❌ <b>가희 모듈 예외</b>\n{notify.escape_html(str(e))[:500]}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def send_now(dry_run: bool = False) -> int:
|
||||
"""날짜 게이트를 무시하고 리마인더 1회 발신 (수동용).
|
||||
|
||||
발송 실패로 그 달을 통째로 놓쳤을 때 복구 수단. last_sent_month 는 건드리지 않아
|
||||
정규 발신(매월 25일)이 그대로 살아있다.
|
||||
"""
|
||||
state = _load_json(STATE_FILE, None)
|
||||
if not state:
|
||||
print("❌ 가희 state 파일 없음", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
handle = _gahee_handle()
|
||||
template = state.get("message_template") or "가계부 업데이트 날이에요. 계좌잔액 보내주시면 자동으로 반영됩니다."
|
||||
before = time.time()
|
||||
if not _send_imessage(handle, template, dry_run=dry_run):
|
||||
return 1
|
||||
if dry_run:
|
||||
print(f"[dry-run] 수신자={handle} · last_sent_month 는 그대로 유지")
|
||||
return 0
|
||||
|
||||
state["last_sent_at"] = _utc_z(_now_kst())
|
||||
state.pop("reply_alert_for", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
|
||||
verdict, detail = _verify_sent(handle, before)
|
||||
if verdict is True:
|
||||
print(f"✅ 발송 확인 — {detail}")
|
||||
return 0
|
||||
if verdict is False:
|
||||
print(f"❌ 발송 실패 — {detail}", file=sys.stderr)
|
||||
state.pop("last_sent_at", None)
|
||||
_save_json(STATE_FILE, state)
|
||||
return 1
|
||||
# chat.db 를 못 읽는 컨텍스트이거나 아직 판정 전. 실패로 단정하지 않는다.
|
||||
print(f"⚠️ 발송 확인 불가 — {detail}")
|
||||
print(f" 답신이 {REPLY_GRACE_DAYS}일간 없으면 골디가 텔레그램으로 알립니다.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
ap = argparse.ArgumentParser(description="가희 잔액 리마인더 (수동 발신용)")
|
||||
ap.add_argument("--send-now", action="store_true",
|
||||
help="날짜 게이트 무시하고 1회 발신. 정규 25일 발신은 유지")
|
||||
ap.add_argument("--dry-run", action="store_true", help="실제 발송 없이 계획만 출력")
|
||||
args = ap.parse_args()
|
||||
if args.send_now:
|
||||
sys.exit(send_now(dry_run=args.dry_run))
|
||||
ap.print_help()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
"""imsg CLI 호출 공용 래퍼.
|
||||
|
||||
호출부가 stdout 만 JSON 파싱하고 stderr·returncode 를 버리면, FDA(전체 디스크 접근 권한)
|
||||
누락 같은 권한 오류가 `Expecting value: line 1 column 1 (char 0)` 파싱 에러로 둔갑한다.
|
||||
2026-08-03 골디 오진 사고의 원인 — 실제 원인은 homebrew node 업그레이드로 게이트웨이
|
||||
바이너리 경로가 바뀌어 FDA 승인이 무효화된 것이었다. 판정을 한곳에 모아 사유를 그대로 노출한다.
|
||||
"""
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
# imsg 가 권한 거부 시 내는 문구 (imsg 0.5.0 기준).
|
||||
FDA_MARKERS = ("Full Disk Access", "Cannot access Messages database")
|
||||
FDA_HINT = (
|
||||
"Messages DB 접근 거부 — 전체 디스크 접근 권한(FDA) 누락. "
|
||||
"skills/whooing-sync/SKILL.md 의 'Full Disk Access' 섹션 참고"
|
||||
)
|
||||
|
||||
|
||||
# `imsg chats` 기본값은 최근 20건이라, 오래 조용한 발신번호(현대카드 SMS·신한은행 등)가
|
||||
# 목록에서 빠진다. 그 상태로 chat id 캐시를 다시 만들면 해당 carrier 수집이 조용히 끊긴다.
|
||||
# chat.db 전체 대화가 130개 수준이라 넉넉히 잡는다. (2026-08-03 실측으로 발견)
|
||||
CHATS_LIMIT = 200
|
||||
|
||||
|
||||
def _reason(stderr: str, stdout: str, fallback: str) -> str:
|
||||
"""실패 사유 한 줄. FDA 마커는 stdout/stderr 어느 쪽에 있어도 잡는다."""
|
||||
err = (stderr or "").strip()
|
||||
out = (stdout or "").strip()
|
||||
if any(m in err or m in out for m in FDA_MARKERS):
|
||||
return FDA_HINT
|
||||
detail = err or out
|
||||
if detail:
|
||||
return " ".join(detail.split())[:200]
|
||||
return fallback
|
||||
|
||||
|
||||
def run_json(args: list, timeout: int) -> "tuple[list, str | None]":
|
||||
"""imsg 실행 → (rows, error). 성공 시 (list[dict], None), 실패 시 ([], 사유).
|
||||
|
||||
결과 0건과 실패를 구분한다 — 둘 다 빈 리스트지만 실패에만 error 가 붙는다.
|
||||
"""
|
||||
try:
|
||||
p = subprocess.run(
|
||||
["imsg"] + list(args),
|
||||
capture_output=True, text=True, timeout=timeout,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return [], "imsg CLI가 설치되어 있지 않습니다"
|
||||
except subprocess.TimeoutExpired:
|
||||
return [], f"타임아웃 ({timeout}s)"
|
||||
except Exception as e:
|
||||
return [], f"{type(e).__name__}: {e}"
|
||||
|
||||
out = p.stdout or ""
|
||||
if p.returncode != 0:
|
||||
return [], _reason(p.stderr, out, f"rc={p.returncode}")
|
||||
|
||||
if not out.strip():
|
||||
# stderr 만 있고 stdout 이 비면 실패. 둘 다 비면 정상적으로 결과 0건.
|
||||
if (p.stderr or "").strip():
|
||||
return [], _reason(p.stderr, "", "빈 출력")
|
||||
return [], None
|
||||
|
||||
rows = []
|
||||
for line in out.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
rows.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
return [], _reason(p.stderr, line, "JSON 파싱 실패")
|
||||
return rows, None
|
||||
|
||||
|
||||
def chats(timeout: int) -> "tuple[list, str | None]":
|
||||
"""대화 목록 조회 → (rows, error). CHATS_LIMIT 만큼 넓게 훑는다."""
|
||||
return run_json(["chats", "--limit", str(CHATS_LIMIT), "--json"], timeout)
|
||||
@@ -74,6 +74,22 @@ SHINHAN_APPROVE_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
# 신한카드 자동납부 형식은 위 승인 SMS와 금액/상호 순서가 반대다:
|
||||
# "[신한카드] 자동납부 정상승인 방효원님 08/06 (일시불) 14,000원 삼천리도시가스"
|
||||
# SHINHAN_APPROVE_RE 를 느슨하게 고치는 대신 별도 패턴으로 먼저 시도한다 —
|
||||
# 느슨하게 고치면 merchant 자리에 "08/06 (일시불)" 이 잡힌다.
|
||||
# kind 대안 순서 주의: `정상승인`·`승인취소` 가 `승인` 보다 앞에 와야 한다(왼쪽 우선).
|
||||
SHINHAN_AUTOPAY_RE = re.compile(
|
||||
r"\[?신한카드\]?\s*자동납부\s*(?P<kind>정상승인|승인취소|매입취소|승인|취소)\s+"
|
||||
r"(?P<who>\S+?님)\s+"
|
||||
r"\d{2}/\d{2}\s+"
|
||||
r"(?:\([^)]*\)\s+)?" # (일시불) / (3개월) — 없는 변형도 통과
|
||||
r"(?P<amount>[\d,]+)\s*원\s+"
|
||||
r"(?P<merchant>[^\n]+)",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
SHINHAN_BANK_RE = re.compile(
|
||||
r"\[Web발신\]\s*\n"
|
||||
r"신한\s*(?P<md>\d{2}/\d{2})\s+(?P<hm>\d{2}:\d{2})\s*\n"
|
||||
@@ -107,7 +123,7 @@ def parse_shinhan_bank(raw: str, created_at: str) -> "dict | None":
|
||||
|
||||
def parse_shinhan_card(raw: str, created_at: str) -> "dict | None":
|
||||
text = _normalize(raw).replace("[Web발신]", "")
|
||||
m = SHINHAN_APPROVE_RE.search(text)
|
||||
m = SHINHAN_AUTOPAY_RE.search(text) or SHINHAN_APPROVE_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
kind_raw = m.group("kind")
|
||||
@@ -135,8 +151,32 @@ KAKAOBANK_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
KAKAOBANK_CARD_RE = re.compile(
|
||||
r"\[Web발신\]\s*\[카카오뱅크\]\s+카드결제\s+"
|
||||
r"(?P<card>\S+)\s+"
|
||||
r"(?P<md>\d{2}/\d{2})\s+(?P<hm>\d{2}:\d{2})\s+"
|
||||
r"(?P<amount>[\d,]+)원\s+"
|
||||
r"(?P<merchant>[^\n]+?)"
|
||||
r"(?:\s+잔액\s+(?P<balance>[\d,]+)원?)?\s*$",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
def parse_kakao_bank(raw: str, created_at: str) -> "dict | None":
|
||||
text = _normalize(raw)
|
||||
card = KAKAOBANK_CARD_RE.search(text)
|
||||
if card:
|
||||
result = {
|
||||
"kind": "withdrawal",
|
||||
"entry_date": _iso_to_kst_date(created_at),
|
||||
"amount": int(card.group("amount").replace(",", "")),
|
||||
"merchant": _normalize(card.group("merchant")).split("\n")[0].strip() or "(메모없음)",
|
||||
"raw": raw,
|
||||
}
|
||||
if card.group("balance"):
|
||||
result["balance"] = int(card.group("balance").replace(",", ""))
|
||||
return result
|
||||
|
||||
m = KAKAOBANK_RE.search(text)
|
||||
if not m:
|
||||
return None
|
||||
@@ -155,15 +195,16 @@ def parse_kakao_bank(raw: str, created_at: str) -> "dict | None":
|
||||
return result
|
||||
|
||||
|
||||
# 현대카드 승인 형식 (취소는 별도 샘플 확보 후 추가):
|
||||
# 현대카드 블루멤버스 승인
|
||||
# 방*원
|
||||
# 5,170원 일시불
|
||||
# 04/25 11:34
|
||||
# 쿠팡
|
||||
# 누적1,466,011원
|
||||
HYUNDAI_CARD_APPROVE_RE = re.compile(
|
||||
r"^\s*현대카드[^\n]*승인\s*\n"
|
||||
# 현대카드 승인/취소 형식 (취소도 헤더 단어만 다르고 본문 구조는 같다):
|
||||
# 현대카드 블루멤버스 승인 현대카드 블루멤버스 취소
|
||||
# 방*원 방*원
|
||||
# 5,170원 일시불 150,000원 일시불
|
||||
# 04/25 11:34 08/02 13:06
|
||||
# 쿠팡 현대가스
|
||||
# 누적1,466,011원 누적4,696,608원
|
||||
# 앞의 `현대카드[^\n]*` 가 greedy 라 "승인취소" 형태도 kind=취소 로 잡힌다.
|
||||
HYUNDAI_CARD_RE = re.compile(
|
||||
r"^\s*현대(?:카드)?[^\n]*(?P<kind>승인|취소)\s*\n"
|
||||
r"(?P<who>[^\n]+)\s*\n"
|
||||
r"(?P<amount>[\d,]+)\s*원[^\n]*\n"
|
||||
r"\d{2}/\d{2}\s+\d{2}:\d{2}\s*\n"
|
||||
@@ -174,8 +215,8 @@ HYUNDAI_CARD_APPROVE_RE = re.compile(
|
||||
|
||||
# RCS/MAAP 형식은 한 줄로 들어온다:
|
||||
# [Web발신] 현대카드 블루멤버스 승인 방*원 84,000원 일시불 05/16 11:57 네이버페이 누적820,000원
|
||||
HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile(
|
||||
r"현대카드[^\n]*승인\s+"
|
||||
HYUNDAI_CARD_INLINE_RE = re.compile(
|
||||
r"현대(?:카드)?[^\n]*(?P<kind>승인|취소)\s+"
|
||||
r"(?P<who>\S+)\s+"
|
||||
r"(?P<amount>[\d,]+)\s*원[^\n]*?"
|
||||
r"\d{2}/\d{2}\s+\d{2}:\d{2}\s+"
|
||||
@@ -184,23 +225,38 @@ HYUNDAI_CARD_APPROVE_INLINE_RE = re.compile(
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
# 현대카드 자동납부 한 줄 형식:
|
||||
# [Web발신] [현대카드] 자동납부 승인 방*원님 라이나생명 31,100원
|
||||
HYUNDAI_AUTOPAY_RE = re.compile(
|
||||
r"\[?현대카드\]?\s*자동납부\s*(?P<kind>승인|취소|승인취소)\s+"
|
||||
r"(?P<who>\S+?님)\s+"
|
||||
r"(?P<merchant>.+?)\s+"
|
||||
r"(?P<amount>[\d,]+)\s*원\s*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def parse_hyundai_card(raw: str, created_at: str) -> "dict | None":
|
||||
text = _normalize(raw).replace("[Web발신]", "").strip()
|
||||
m = HYUNDAI_CARD_APPROVE_RE.search(text) or HYUNDAI_CARD_APPROVE_INLINE_RE.search(text)
|
||||
m = (
|
||||
HYUNDAI_AUTOPAY_RE.search(text)
|
||||
or HYUNDAI_CARD_RE.search(text)
|
||||
or HYUNDAI_CARD_INLINE_RE.search(text)
|
||||
)
|
||||
if not m:
|
||||
return None
|
||||
amount = int(m.group("amount").replace(",", ""))
|
||||
merchant = re.sub(r"\s+", " ", m.group("merchant").strip()) or "(가맹점없음)"
|
||||
result = {
|
||||
"kind": "card_approval",
|
||||
"kind": "card_cancel" if m.group("kind") == "취소" else "card_approval",
|
||||
"entry_date": _iso_to_kst_date(created_at),
|
||||
"amount": amount,
|
||||
"merchant": merchant,
|
||||
"raw": raw,
|
||||
}
|
||||
if m.group("cumulative"):
|
||||
result["cumulative"] = int(m.group("cumulative").replace(",", ""))
|
||||
cumulative = m.groupdict().get("cumulative")
|
||||
if cumulative:
|
||||
result["cumulative"] = int(cumulative.replace(",", ""))
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""은행 이체 SMS ↔ 증권 계좌 입출금(kt00015) 짝 맞추기.
|
||||
|
||||
은행에서 증권으로 이체하면 은행 SMS 에는 **입금자명(=본인 이름)만** 찍혀서
|
||||
어디로 갔는지 알 수 없다. 그래서 `방효원` 같은 자체이체 룰에 걸려
|
||||
`기초잔액 ← 은행` 이 되고, 자본 분개 금지 가드에 막혀 **미반영**된다.
|
||||
반대편(증권 입금)은 SMS 가 아예 없어 월 1회 reconcile 차액으로만 나타나는데,
|
||||
그 차액은 전부 `주식평가수익` 으로 분개되므로 **이체가 손익으로 둔갑**한다.
|
||||
|
||||
이 모듈은 그 짝을 키움 kt00015 입출금 내역에서 찾아
|
||||
`증권(효원) ← 하나은행(효원)` 이체 한 건으로 만들어 준다.
|
||||
|
||||
⚠️ **capital-block 직전에만 호출한다** — 평시 키움 콜 0. 이체가 실제로 있었던
|
||||
사이클에만 계좌 수만큼(4콜) 조회한다.
|
||||
⚠️ **애매하면 매칭하지 않는다** — 후보가 여럿이면 포기하고 기존 알림 경로로
|
||||
보낸다. 틀린 분개보다 사람이 보는 편이 낫다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
ROOT = Path("/Users/snowoyh/.openclaw")
|
||||
STOCK_SCRIPTS = ROOT / "agents" / "stock" / "workspace" / "scripts"
|
||||
MATCHED_FILE = ROOT / "agents" / "budget" / "workspace" / "state" / "whooing_securities_matched.json"
|
||||
|
||||
# 증권 계좌 라벨 → 후잉 자산 계정명. 라벨 prefix 규칙은 send_balance_to_budget.py 와 동일.
|
||||
GAHEE_PREFIX = "가희"
|
||||
OWNER_ASSET = {"self": "증권(효원)", "gahee": "증권(가희)"}
|
||||
|
||||
# 은행 SMS 종류 → 기대되는 증권 쪽 방향.
|
||||
OPPOSITE = {"withdrawal": "IN", "deposit": "OUT"}
|
||||
|
||||
# 시각까지 일치하는 후보를 우선한다 (오픈뱅킹은 즉시 반영이라 실측 12초 차).
|
||||
# 후보가 여럿일 때만 쓰는 tie-breaker 이지 1차 필터가 아니다 —
|
||||
# 증권사 처리가 늦어 분 단위로 벌어지는 경우가 있어 창으로 잘라내면 놓친다.
|
||||
TIME_TIEBREAK_SECONDS = 600
|
||||
|
||||
|
||||
def _owner_of(label: str) -> str:
|
||||
return "gahee" if label.startswith(GAHEE_PREFIX) else "self"
|
||||
|
||||
|
||||
def row_key(label: str, row: dict) -> str:
|
||||
"""kt00015 행에는 고유 ID 가 없다. 중복 분개 방지용 합성 키.
|
||||
같은 날 같은 금액을 두 번 이체하면 time 으로 갈린다."""
|
||||
return f"{row['date']}|{label}|{row['io_tp']}|{row['amount']}|{row.get('time') or ''}"
|
||||
|
||||
|
||||
def _load_matched() -> dict:
|
||||
try:
|
||||
with MATCHED_FILE.open(encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def mark_matched(key: str, meta: dict) -> None:
|
||||
"""분개 성공 후 호출. POST 성공한 뒤에만 기록해야 재시도 여지가 남는다."""
|
||||
data = _load_matched()
|
||||
data[key] = {**meta, "matched_at": datetime.now(KST).isoformat()}
|
||||
# 오래된 항목 정리 (180일) — 무한 증식 방지.
|
||||
cutoff = (datetime.now(KST) - timedelta(days=180)).strftime("%Y%m%d")
|
||||
data = {k: v for k, v in data.items() if k.split("|", 1)[0] >= cutoff}
|
||||
MATCHED_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = MATCHED_FILE.with_suffix(".tmp")
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, MATCHED_FILE)
|
||||
|
||||
|
||||
def _fetch_day(base_dt: str) -> dict[str, list[dict]] | None:
|
||||
"""전 계좌의 그날 입출금. 조회 실패는 None (= 판단 보류, 매칭 안 함)."""
|
||||
sys.path.insert(0, str(STOCK_SCRIPTS))
|
||||
try:
|
||||
import kiwoom_client as kc
|
||||
import investment_principal as ip
|
||||
except Exception as e: # noqa: BLE001 — import 실패해도 후잉 동기화는 계속돼야 한다
|
||||
sys.stderr.write(f"securities_match: 키움 모듈 import 실패: {e}\n")
|
||||
return None
|
||||
|
||||
out: dict[str, list[dict]] = {}
|
||||
for acc in kc.list_accounts():
|
||||
label = acc["label"]
|
||||
try:
|
||||
rows = kc.get_cash_flow(label, base_dt=base_dt)
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.stderr.write(f"securities_match: {label} kt00015 조회 실패: {e}\n")
|
||||
return None # 일부만 보고 판단하면 오매칭 — 전부 성공해야 진행
|
||||
out[label] = [r for r in rows if ip.is_principal_flow(r["rmrk"])]
|
||||
return out
|
||||
|
||||
|
||||
def _drop_internal_transfers(day: dict[str, list[dict]]) -> dict[str, list[dict]]:
|
||||
"""증권 계좌 간 대체(예: 가희_일반 → 가희_ISA)를 후보에서 뺀다.
|
||||
후잉에선 같은 자산이라 분개 대상이 아닌데, 금액이 같으면 은행 SMS 와
|
||||
오매칭될 수 있다. 서로 다른 계좌에 같은 금액의 IN/OUT 이 함께 있으면 제외."""
|
||||
ins = {(lb, r["amount"]) for lb, rows in day.items() for r in rows if r["io_tp"] == "IN"}
|
||||
outs = {(lb, r["amount"]) for lb, rows in day.items() for r in rows if r["io_tp"] == "OUT"}
|
||||
paired = {a for lb, a in ins} & {a for lb, a in outs}
|
||||
if not paired:
|
||||
return day
|
||||
cleaned: dict[str, list[dict]] = {}
|
||||
for lb, rows in day.items():
|
||||
keep = []
|
||||
for r in rows:
|
||||
if r["amount"] in paired and any(
|
||||
other != lb and any(
|
||||
o["amount"] == r["amount"] and o["io_tp"] != r["io_tp"] for o in day[other]
|
||||
)
|
||||
for other in day
|
||||
):
|
||||
continue
|
||||
keep.append(r)
|
||||
cleaned[lb] = keep
|
||||
return cleaned
|
||||
|
||||
|
||||
def _sms_time(created_at_utc: str | None) -> datetime | None:
|
||||
if not created_at_utc:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(created_at_utc.replace("Z", "+00:00")).astimezone(KST)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def find_match(entry_date: str, amount: int, bank_kind: str,
|
||||
created_at_utc: str | None = None) -> dict | None:
|
||||
"""은행 SMS 한 건에 대응하는 증권 입출금 행을 찾는다.
|
||||
|
||||
entry_date: 'YYYYMMDD' (은행 SMS 기준일)
|
||||
amount: 원 (양수)
|
||||
bank_kind: 'withdrawal' (은행→증권) | 'deposit' (증권→은행)
|
||||
created_at_utc: SMS 수신 시각 ISO — 후보가 여럿일 때 tie-break 에만 쓴다
|
||||
|
||||
반환 (매칭 성공 시): {label, asset, row, key}
|
||||
반환 None: 후보 없음 / 후보 여럿 / 이미 처리됨 / 조회 실패
|
||||
"""
|
||||
want_io = OPPOSITE.get(bank_kind)
|
||||
if want_io is None:
|
||||
return None
|
||||
|
||||
day = _fetch_day(entry_date)
|
||||
if day is None:
|
||||
return None
|
||||
day = _drop_internal_transfers(day)
|
||||
|
||||
matched = _load_matched()
|
||||
cands = [
|
||||
{"label": lb, "row": r, "key": row_key(lb, r)}
|
||||
for lb, rows in day.items()
|
||||
for r in rows
|
||||
if r["io_tp"] == want_io and r["amount"] == amount and row_key(lb, r) not in matched
|
||||
]
|
||||
if not cands:
|
||||
return None
|
||||
if len(cands) > 1:
|
||||
# 시각이 가까운 후보가 정확히 하나면 그걸 고른다. 아니면 포기.
|
||||
sms_at = _sms_time(created_at_utc)
|
||||
near = []
|
||||
if sms_at is not None:
|
||||
for c in cands:
|
||||
t = c["row"].get("time")
|
||||
if not t:
|
||||
continue
|
||||
try:
|
||||
rt = datetime.strptime(f"{entry_date} {t}", "%Y%m%d %H:%M:%S").replace(tzinfo=KST)
|
||||
except ValueError:
|
||||
continue
|
||||
if abs((rt - sms_at).total_seconds()) <= TIME_TIEBREAK_SECONDS:
|
||||
near.append(c)
|
||||
if len(near) != 1:
|
||||
sys.stderr.write(
|
||||
f"securities_match: {entry_date} {amount:,}원 {want_io} 후보 {len(cands)}건 — 매칭 포기\n")
|
||||
return None
|
||||
cands = near
|
||||
|
||||
c = cands[0]
|
||||
c["asset"] = OWNER_ASSET[_owner_of(c["label"])]
|
||||
return c
|
||||
|
||||
|
||||
def build_payload(match: dict, bank_asset: str, entry_date: str, amount: int,
|
||||
bank_kind: str, balance: int | None = None) -> dict:
|
||||
"""후잉 이체 payload. 은행→증권이면 left=증권, 증권→은행이면 left=은행."""
|
||||
if bank_kind == "withdrawal":
|
||||
left, right = match["asset"], bank_asset
|
||||
else:
|
||||
left, right = bank_asset, match["asset"]
|
||||
memo = f"{match['label']} {match['row']['rmrk']}"
|
||||
if balance is not None:
|
||||
memo += f" / 잔액 : {balance:,}원"
|
||||
return {
|
||||
"entry_date": entry_date,
|
||||
"money": amount,
|
||||
"item": "이체",
|
||||
"left": left,
|
||||
"right": right,
|
||||
"memo": memo,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 진단용: python3 securities_match.py YYYYMMDD 금액 withdrawal|deposit
|
||||
if len(sys.argv) < 4:
|
||||
print(__doc__)
|
||||
print("usage: securities_match.py YYYYMMDD AMOUNT withdrawal|deposit [SMS_ISO_UTC]")
|
||||
raise SystemExit(2)
|
||||
m = find_match(sys.argv[1], int(sys.argv[2]), sys.argv[3],
|
||||
sys.argv[4] if len(sys.argv) > 4 else None)
|
||||
if m is None:
|
||||
print("매칭 없음")
|
||||
raise SystemExit(1)
|
||||
print(json.dumps(m, ensure_ascii=False, indent=2))
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""후잉 OpenAPI 잔액(bs.json) 조회.
|
||||
"""후잉 OpenAPI 클라이언트 — 잔액(bs.json) 조회 + 거래(entries.json) 조회·감액.
|
||||
|
||||
Usage:
|
||||
whooing_balance.py # 모든 섹션의 현재 잔액
|
||||
@@ -11,11 +11,13 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
CRED_PATH = Path("/Users/snowoyh/.openclaw/credentials/whooing.json")
|
||||
@@ -41,6 +43,130 @@ def api_get(endpoint: str, api_key: str, params: dict | None = None) -> dict:
|
||||
return data["results"]
|
||||
|
||||
|
||||
def api_key_from_cred() -> str:
|
||||
"""credentials 에서 API 키 문자열 1회 생성."""
|
||||
api_cfg = json.loads(CRED_PATH.read_text())["api"]
|
||||
return build_api_key(api_cfg["app_id"], api_cfg["token"], api_cfg["signature"])
|
||||
|
||||
|
||||
def api_request(method: str, endpoint: str, api_key: str,
|
||||
params: dict | None = None, body: dict | None = None) -> dict:
|
||||
"""후잉 OpenAPI 호출. GET 은 쿼리스트링, PUT 은 본문으로 파라미터를 보낸다.
|
||||
|
||||
⚠️ DELETE 는 쓸 수 없다. 후잉 서버가 DELETE 요청의 파라미터를 쿼리·본문(urlencoded/JSON)·
|
||||
쿠키·경로 어디에서도 읽지 못해 항상 `section_id parameter is required` 로 실패한다
|
||||
(2026-08-04 전수 실측. 같은 본문을 PUT 으로 보내면 정상이라 후잉 쪽 문제다).
|
||||
거래를 지우는 대신 PUT 으로 금액을 줄이는 이유가 이것이다.
|
||||
"""
|
||||
url = f"{BASE}/{endpoint}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
data = None
|
||||
if body:
|
||||
# 후잉은 '+' 를 공백으로 디코드하지 않는다. quote_via=quote 로 공백을 %20 으로 보낸다.
|
||||
data = urllib.parse.urlencode(body, quote_via=urllib.parse.quote).encode()
|
||||
req = urllib.request.Request(url, data=data, method=method, headers={"X-API-KEY": api_key})
|
||||
if data:
|
||||
req.add_header("Content-Type", "application/x-www-form-urlencoded")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
parsed = json.loads(resp.read().decode("utf-8"))
|
||||
if parsed.get("code") != 200:
|
||||
raise RuntimeError(
|
||||
f"후잉 API error {parsed.get('code')}: {parsed.get('message')} "
|
||||
f"{parsed.get('error_parameters') or ''} ({method} {endpoint})"
|
||||
)
|
||||
return parsed["results"]
|
||||
|
||||
|
||||
def _section_list(api_key: str) -> list:
|
||||
sections = api_request("GET", "sections.json", api_key)
|
||||
if isinstance(sections, dict):
|
||||
return sections.get("sections") or sections.get("rows") or list(sections.values())
|
||||
return sections
|
||||
|
||||
|
||||
def _name_to_account_id(api_key: str, section_id) -> dict[str, str]:
|
||||
raw = api_request("GET", "accounts.json", api_key, {"section_id": section_id})
|
||||
out: dict[str, str] = {}
|
||||
for acc_list in raw.values():
|
||||
if not isinstance(acc_list, list):
|
||||
continue
|
||||
for a in acc_list:
|
||||
aid = str(a.get("account_id"))
|
||||
out[a.get("title") or aid] = aid
|
||||
return out
|
||||
|
||||
|
||||
def _memo_has_merchant(memo: str, merchant: str) -> bool:
|
||||
"""memo 에 merchant 가 토큰 단위로 들어있는지.
|
||||
|
||||
단순 substring 이면 '쿠팡' 이 '쿠팡페이주' 승인까지 잡아 엉뚱한 거래를 감액한다
|
||||
(2026-08-04 실제로 두 상호가 같은 날 같은 금액으로 있었다). 카드 SMS 는 상호를
|
||||
줄바꿈·공백으로 구분하므로 앞뒤가 공백이거나 문자열 끝일 때만 인정한다.
|
||||
"""
|
||||
if not memo or not merchant:
|
||||
return False
|
||||
for m in re.finditer(re.escape(merchant), memo):
|
||||
before = memo[m.start() - 1] if m.start() > 0 else " "
|
||||
after = memo[m.end()] if m.end() < len(memo) else " "
|
||||
if before.isspace() and after.isspace():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def find_card_entries(card_account: str, money: int, merchant: str,
|
||||
on_date: str, window_days: int = 30) -> list[dict]:
|
||||
"""카드 취소의 원본 승인 거래 후보를 찾는다.
|
||||
|
||||
card_account 로 결제된 거래 중 memo 에 merchant 가 들어있고 금액이 취소액 이상인 것을
|
||||
[on_date - window_days, on_date] 구간에서 찾는다. 금액 일치(전액취소)를 우선 반환하고,
|
||||
없으면 초과분(부분취소 후보)을 반환한다.
|
||||
|
||||
⚠️ item 이 아니라 memo 로 매칭한다 — merchant_map 의 contains 룰이 item 을 재작성해서
|
||||
(동행복권→'복권') item 은 상호와 다를 수 있지만, memo 는 항상 승인 SMS 원문이다.
|
||||
⚠️ 판단은 호출측이 한다. 정확히 1건일 때만 손대는 게 안전하다.
|
||||
"""
|
||||
api_key = api_key_from_cred()
|
||||
start = (datetime.strptime(on_date, "%Y%m%d") - timedelta(days=window_days)).strftime("%Y%m%d")
|
||||
exact, over = [], []
|
||||
for sec in _section_list(api_key):
|
||||
sid = sec.get("section_id") or sec.get("id")
|
||||
card_id = _name_to_account_id(api_key, sid).get(card_account)
|
||||
if not card_id:
|
||||
continue
|
||||
rows = api_request("GET", "entries.json", api_key, {
|
||||
"section_id": sid, "start_date": start, "end_date": on_date,
|
||||
})
|
||||
for row in (rows.get("rows") or []):
|
||||
if str(row.get("r_account_id")) != card_id:
|
||||
continue
|
||||
if not _memo_has_merchant(row.get("memo") or "", merchant):
|
||||
continue
|
||||
row_money = row.get("money") or 0
|
||||
if row_money == money:
|
||||
exact.append({**row, "section_id": sid})
|
||||
elif row_money > money:
|
||||
over.append({**row, "section_id": sid})
|
||||
return exact or over
|
||||
|
||||
|
||||
def set_entry_money(entry: dict, money: int) -> None:
|
||||
"""거래 금액을 money 로 바꾼다(PUT). money=0 도 후잉이 받는다(2026-08-04 실측).
|
||||
|
||||
조회한 나머지 필드를 그대로 되돌려보내 보존한다 — 부분 PUT 의 필드 보존 동작에
|
||||
기대지 않는 편이 안전하다. entry_date 는 조회 시 '20260804.0000' 형태라 소수부를 뗀다.
|
||||
"""
|
||||
api_request("PUT", f"entries/{entry['entry_id']}.json", api_key_from_cred(), body={
|
||||
"section_id": entry["section_id"],
|
||||
"entry_date": str(entry.get("entry_date") or "").split(".")[0],
|
||||
"l_account_id": entry.get("l_account_id"),
|
||||
"r_account_id": entry.get("r_account_id"),
|
||||
"item": entry.get("item") or "",
|
||||
"money": str(money),
|
||||
"memo": entry.get("memo") or "",
|
||||
})
|
||||
|
||||
|
||||
def fmt_won(n: int) -> str:
|
||||
return f"{n:,}원"
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -22,9 +21,11 @@ from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import parsers as sms_parsers
|
||||
import imsg_cli
|
||||
import notify
|
||||
import whooing_balance
|
||||
import gahee_reminder
|
||||
import securities_match
|
||||
|
||||
# 후잉 웹훅은 HTTP 200을 반환하면서 본문에 "fail"/"Error : ..." 를 줄 수 있어 본문 검증 필수.
|
||||
SUCCESS_BODY = "done"
|
||||
@@ -80,7 +81,7 @@ def _format_raw_fallback(new_raw: list) -> str:
|
||||
|
||||
|
||||
def _format_balance_mismatch(info: dict, sms_b: int, whoo_b: int, diff: int, prev_diff,
|
||||
recovery_hint=None) -> str:
|
||||
recovery_hint=None, gaps=None) -> str:
|
||||
"""SMS 잔액(자산) 또는 누적사용액(부채/카드)과 후잉 잔액 차이 → 텔레그램 메시지(HTML)."""
|
||||
label = notify.escape_html(info["label"])
|
||||
acct = notify.escape_html(info["account"])
|
||||
@@ -88,42 +89,153 @@ def _format_balance_mismatch(info: dict, sms_b: int, whoo_b: int, diff: int, pre
|
||||
kind_word = "누적사용액" if side == "liability" else "잔액"
|
||||
actual_whoo_b = info.get("actual_whooing_balance", whoo_b)
|
||||
payment_offset = info.get("liability_payment_offset", 0) or 0
|
||||
if payment_offset and actual_whoo_b != whoo_b:
|
||||
whoo_line = (
|
||||
f"• 후잉: <b>{actual_whoo_b:,}원</b>"
|
||||
f" + 카드대금 보정 <b>{payment_offset:,}원</b>"
|
||||
f" → <b>{whoo_b:,}원</b>"
|
||||
)
|
||||
matched_word = "후잉(카드대금 보정 후)"
|
||||
else:
|
||||
whoo_line = f"• 후잉: <b>{whoo_b:,}원</b>"
|
||||
matched_word = "후잉"
|
||||
has_offset = bool(payment_offset and actual_whoo_b != whoo_b)
|
||||
matched_word = "후잉(카드대금 보정 후)" if has_offset else "후잉"
|
||||
|
||||
if diff == 0:
|
||||
recovered = abs(prev_diff) if prev_diff is not None else 0
|
||||
recovered_line = f"\n회복 금액: <b>{recovered:,}원</b>" if recovered else ""
|
||||
hint_line = f"\n반영 항목: <b>{notify.escape_html(recovery_hint)}</b>" if recovery_hint else ""
|
||||
return (
|
||||
f"✅ <b>{kind_word} 정합 회복</b>\n\n"
|
||||
f"<b>{label}</b> ({acct})\n"
|
||||
f"SMS / {matched_word} 모두 <b>{sms_b:,}원</b> 일치"
|
||||
f"{recovered_line}"
|
||||
f"{hint_line}"
|
||||
)
|
||||
lines = [
|
||||
f"✅ <b>{kind_word} 정합 회복</b>",
|
||||
"",
|
||||
f"📌 <b>{label}</b> ({acct})",
|
||||
f"• SMS · {matched_word}: <b>{sms_b:,}원</b> (일치)",
|
||||
]
|
||||
if recovered:
|
||||
lines.append(f"• 불일치 해소: <b>{recovered:,}원</b>")
|
||||
if recovery_hint:
|
||||
lines.append("─────────────")
|
||||
lines.append(f"• 마지막 반영 거래: <b>{notify.escape_html(recovery_hint)}</b>")
|
||||
return "\n".join(lines)
|
||||
|
||||
sign = "+" if diff > 0 else ""
|
||||
if side == "liability":
|
||||
direction = "결제 누락 의심" if diff > 0 else "사용 거래 누락 의심"
|
||||
else:
|
||||
direction = "출금 누락 또는 입금 중복" if diff > 0 else "입금 누락 의심"
|
||||
body = (
|
||||
f"⚠️ <b>{kind_word} 불일치</b>\n\n"
|
||||
f"<b>{label}</b> ({acct})\n"
|
||||
f"• SMS: <b>{sms_b:,}원</b>\n"
|
||||
f"{whoo_line}\n"
|
||||
f"• 차이: <b>{sign}{diff:,}원</b> ({direction})"
|
||||
lines = [
|
||||
f"⚠️ <b>{kind_word} 불일치</b>",
|
||||
"",
|
||||
f"📌 <b>{label}</b> ({acct})",
|
||||
f"• SMS {kind_word}: <b>{sms_b:,}원</b>",
|
||||
]
|
||||
if has_offset:
|
||||
lines.append(
|
||||
f"• 후잉 {kind_word}: <b>{actual_whoo_b:,}원</b>"
|
||||
f" + 카드대금 보정 <b>{payment_offset:,}원</b>"
|
||||
f" = <b>{whoo_b:,}원</b>"
|
||||
)
|
||||
else:
|
||||
lines.append(f"• 후잉 {kind_word}: <b>{whoo_b:,}원</b>")
|
||||
lines.append(f"• 차이: <b>{sign}{diff:,}원</b> → {direction}")
|
||||
if prev_diff is not None and prev_diff != diff:
|
||||
body += f"\n <i>이전 차이: {prev_diff:+,}원 → 변동</i>"
|
||||
return body + "\n\n<i>SMS 누락·잘못 분개·기간 외 거래 등 점검 필요.</i>"
|
||||
lines.append(f"• 직전 차이: {prev_diff:+,}원 (변동)")
|
||||
if gaps:
|
||||
lines.append("─────────────")
|
||||
lines.append("🔎 <b>누적 끊김 구간</b> (SMS 미수신 승인 의심)")
|
||||
for g in gaps[:3]:
|
||||
lines.append(f"• {notify.escape_html(g['from'])} ~ {notify.escape_html(g['to'])}"
|
||||
f" · <b>{g['amount']:,}원</b>")
|
||||
if len(gaps) > 3:
|
||||
lines.append(f"• 그 외 {len(gaps) - 3}건")
|
||||
lines.append("<i>톨비처럼 문자가 안 오는 승인일 수 있어요.</i>")
|
||||
lines.append("")
|
||||
lines.append("<i>ℹ️ SMS 누락·잘못 분개·기간 외 거래 등 점검이 필요해요.</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _cumulative_gap_scan(account_name: str, days: int = 45) -> list:
|
||||
"""카드 승인 SMS 의 누적사용액 연속성을 검사해 'SMS 없이 누적에만 반영된 승인'을 찾는다.
|
||||
|
||||
누적은 승인마다 `직전 누적 + 승인액`(취소는 -)이 되어야 한다. 어긋난 구간이 있으면
|
||||
그 사이에 문자가 오지 않는 승인(톨비 등)이 있었다는 뜻이다. 잔액 차이만으로는
|
||||
"얼마 틀어졌다"까지만 알 수 있지만, 이 검사는 어느 승인 사이에 얼마가 빠졌는지 짚는다.
|
||||
|
||||
⚠️ 후잉 조회가 들어가므로 diff != 0 인 카드 계정에서만 호출한다.
|
||||
⚠️ 양의 격차만 본다 — 카드대금 상환·정산은 누적을 떨어뜨리는데 이걸 격차로 세면 오탐이다.
|
||||
⚠️ 이미 손으로 보정해 둔 건(메모가 SMS 원문이 아닌 항목)은 구간에서 차감한다.
|
||||
안 그러면 톨비를 넣어준 뒤에도 같은 구간이 계속 미해결로 뜬다.
|
||||
"""
|
||||
start = (datetime.now(KST) - timedelta(days=days)).strftime("%Y%m%d")
|
||||
end = datetime.now(KST).strftime("%Y%m%d")
|
||||
api_key = whooing_balance.api_key_from_cred()
|
||||
sections = whooing_balance._section_list(api_key)
|
||||
if not sections:
|
||||
return []
|
||||
sid = sections[0].get("section_id") or sections[0].get("id")
|
||||
aid = str(whooing_balance._name_to_account_id(api_key, sid).get(account_name))
|
||||
rows = whooing_balance.api_request("GET", "entries.json", api_key, {
|
||||
"section_id": sid, "start_date": start, "end_date": end, "limit": 500,
|
||||
})
|
||||
|
||||
sms, corrections = [], []
|
||||
for r in (rows.get("rows") or []):
|
||||
if aid not in (str(r.get("l_account_id")), str(r.get("r_account_id"))):
|
||||
continue
|
||||
entry_date = str(r.get("entry_date") or "").split(".")[0]
|
||||
memo = r.get("memo") or ""
|
||||
parsed = sms_parsers.parse_hyundai_card(memo, "1970-01-01T00:00:00Z")
|
||||
stamp = re.search(r"\d{2}/\d{2}\s+\d{2}:\d{2}", memo)
|
||||
if parsed and parsed.get("cumulative") is not None and stamp:
|
||||
sms.append({
|
||||
"sort": (entry_date, stamp.group(0)),
|
||||
"date": entry_date,
|
||||
"when": stamp.group(0),
|
||||
"cumulative": parsed["cumulative"],
|
||||
"amount": parsed["amount"],
|
||||
"kind": parsed["kind"],
|
||||
})
|
||||
else:
|
||||
corrections.append({"date": entry_date, "money": int(r.get("money") or 0)})
|
||||
|
||||
sms.sort(key=lambda x: x["sort"])
|
||||
gaps = []
|
||||
for prev, cur in zip(sms, sms[1:]):
|
||||
sign = -1 if cur["kind"] == "card_cancel" else 1
|
||||
missing = cur["cumulative"] - (prev["cumulative"] + sign * cur["amount"])
|
||||
if missing > 0:
|
||||
gaps.append({
|
||||
"from": prev["when"], "to": cur["when"],
|
||||
"from_date": prev["date"], "to_date": cur["date"],
|
||||
"amount": missing,
|
||||
})
|
||||
|
||||
# 이미 보정해 둔 항목을 구간별로 소진시킨다. 한 보정이 여러 구간에 중복 차감되지 않도록
|
||||
# 앞선 구간부터 하나씩 소비한다.
|
||||
unused = sorted(corrections, key=lambda c: c["date"])
|
||||
for gap in gaps:
|
||||
for corr in unused:
|
||||
if corr["money"] <= 0 or gap["amount"] <= 0:
|
||||
continue
|
||||
if gap["from_date"] <= corr["date"] <= gap["to_date"]:
|
||||
applied = min(corr["money"], gap["amount"])
|
||||
corr["money"] -= applied
|
||||
gap["amount"] -= applied
|
||||
return [g for g in gaps if g["amount"] > 0]
|
||||
|
||||
|
||||
def _format_capital_blocked(blocked: list) -> str:
|
||||
"""기초잔액(자본) 분개로 갈 뻔해 보류한 건 → 텔레그램 메시지(HTML).
|
||||
|
||||
후잉에 아무것도 넣지 않았으므로 관리자님이 직접 분개해야 한다. 그대로 두면
|
||||
해당 계좌의 잔액 불일치 알림이 뒤이어 뜨므로 놓쳐도 두 번째 그물이 있다.
|
||||
"""
|
||||
lines = [f"⛔ <b>기초잔액 분개 보류 {len(blocked)}건</b>", ""]
|
||||
for b in blocked[:5]:
|
||||
pay = b.get("payload") or {}
|
||||
try:
|
||||
money = f"{int(pay.get('money', 0)):,}원"
|
||||
except Exception:
|
||||
money = f"{pay.get('money', '')}원"
|
||||
lines.append(
|
||||
f"• <b>{notify.escape_html(b['label'])}</b> · {notify.format_kst(b['created_at'])}"
|
||||
f"\n {money} · {notify.escape_html(pay.get('item') or '')}"
|
||||
f"\n <i>{notify.escape_html(pay.get('left') or '')} ← {notify.escape_html(pay.get('right') or '')}</i>"
|
||||
)
|
||||
if len(blocked) > 5:
|
||||
lines.append(f"• 외 {len(blocked)-5}건")
|
||||
lines.append("")
|
||||
lines.append("<i>후잉에 반영하지 않았어요. 직접 분개해 주세요 — 자체이체면 실제 출금 계좌로, 아니면 성격에 맞는 계정으로요.</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_sync_failure(f: dict) -> str:
|
||||
@@ -202,7 +314,11 @@ MONEY_AMOUNT_RE = re.compile(r"(?:\d[\d,]*\s*원|(?:입금|출금)\s+\d[\d,]*)")
|
||||
|
||||
# 주유소 카드 결제는 보통 150,000원 선승인 → 실제 주유금액 승인 → 150,000원 승인취소 순서로 온다.
|
||||
# 선승인/취소는 후잉에 기록하지 않고, 실제 결제만 차량유지비/주유비로 남긴다.
|
||||
FUEL_KEYWORDS = ("석유", "주유소", "주유")
|
||||
# "현대가스"·"한경에너"는 상호에 주유/석유가 안 들어가는 충전소라 직접 등록 (2026-08-03 관리자님 확인).
|
||||
# 새 주유소를 만나면 여기와 whooing_merchant_map.json contains **양쪽에** 상호를 추가한다 —
|
||||
# 이 상수는 선승인/취소 스킵, merchant_map 은 실제 주유 결제 분류로 한 쌍이다.
|
||||
# ⚠️ 한경에너는 선승인이 149,900원이라 FUEL_PREAUTH_AMOUNT 정확일치엔 안 걸린다(분류만 적용).
|
||||
FUEL_KEYWORDS = ("석유", "주유소", "주유", "현대가스", "한경에너")
|
||||
FUEL_PREAUTH_AMOUNT = 150_000
|
||||
|
||||
|
||||
@@ -213,7 +329,7 @@ def _is_fuel_text(text: str) -> bool:
|
||||
def _should_skip_fuel_preauth(parsed: dict | None, text: str) -> bool:
|
||||
"""주유소 15만원 선승인/취소 스킵.
|
||||
|
||||
parsed 가 있으면 구조화 필드 기준으로 판단한다. 현대카드 취소처럼 아직 파서가 없는 형식은
|
||||
parsed 가 있으면 구조화 필드 기준으로 판단한다. 파서가 못 읽은 형식은 폴백으로
|
||||
raw 텍스트에서 '취소' + '150,000원' + 주유 키워드 조합만 보수적으로 스킵한다.
|
||||
"""
|
||||
if parsed:
|
||||
@@ -274,39 +390,33 @@ def resolve_chat_ids(carriers):
|
||||
needed = set(carriers.keys())
|
||||
if needed.issubset(cached_keys):
|
||||
return {k: cached[k] for k in needed}
|
||||
try:
|
||||
chats_raw = subprocess.run(
|
||||
["imsg", "chats", "--json"],
|
||||
capture_output=True, text=True, timeout=IMSG_TIMEOUT,
|
||||
)
|
||||
chats = [json.loads(l) for l in chats_raw.stdout.splitlines() if l.strip()]
|
||||
except FileNotFoundError:
|
||||
print("❌ imsg CLI가 설치되어 있지 않습니다.", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
except Exception as e:
|
||||
print(f"❌ imsg chats 실행 실패: {e}", file=sys.stderr)
|
||||
chats, err = imsg_cli.chats(IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"❌ imsg chats 실행 실패: {err}", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
result = {}
|
||||
for sender in carriers.keys():
|
||||
ids = [c["id"] for c in chats if c.get("identifier") == sender and c.get("id") is not None]
|
||||
result[sender] = ids
|
||||
# 전 carrier 가 빈 리스트면 조회 자체가 실패한 것으로 본다. 이 상태를 캐시에 저장하면
|
||||
# 이후 모든 실행(launchd 포함)이 캐시에 적중해 조용히 0건 수집을 영구 반복한다.
|
||||
# 일부만 비는 건 정상 — 메시지 이력이 없는 carrier 가 있을 수 있다.
|
||||
if not any(result.values()):
|
||||
print("❌ imsg chats 결과에 매핑된 발신번호가 하나도 없습니다 — 캐시 저장 안 함", file=sys.stderr)
|
||||
sys.exit(3)
|
||||
save_json(CHAT_IDS_CACHE, result)
|
||||
return result
|
||||
|
||||
|
||||
def fetch_messages(chat_id, since):
|
||||
cmd = ["imsg", "history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
|
||||
args = ["history", "--chat-id", str(chat_id), "--limit", str(HISTORY_LIMIT), "--json"]
|
||||
if since:
|
||||
cmd += ["--start", since]
|
||||
try:
|
||||
raw = subprocess.run(cmd, capture_output=True, text=True, timeout=IMSG_TIMEOUT)
|
||||
return [json.loads(l) for l in raw.stdout.splitlines() if l.strip()]
|
||||
except subprocess.TimeoutExpired:
|
||||
print(f"⚠️ chat-id {chat_id} history 타임아웃", file=sys.stderr)
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"⚠️ chat-id {chat_id} history 실패: {e}", file=sys.stderr)
|
||||
args += ["--start", since]
|
||||
rows, err = imsg_cli.run_json(args, IMSG_TIMEOUT)
|
||||
if err:
|
||||
print(f"⚠️ chat-id {chat_id} history 실패: {err}", file=sys.stderr)
|
||||
return []
|
||||
return rows
|
||||
|
||||
|
||||
# --- 룰 엔진 (whooing_overrides.json) -----------------------------------------
|
||||
@@ -510,8 +620,9 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
|
||||
return {**base, "left": cat, "right": carrier_acct}
|
||||
return {**base, "left": "기타비용", "right": carrier_acct}
|
||||
|
||||
if kind == "card_cancel":
|
||||
return None # raw 폴백 (후잉이 원거래 찾아 상쇄)
|
||||
# card_cancel 은 여기서 payload 를 만들지 않는다 — 처리 루프의 _handle_card_cancel 이
|
||||
# 원본 승인 거래를 찾아 PUT 으로 감액한다. 좌우를 뒤집은 역분개는 후잉이 거부한다
|
||||
# (비용 계정은 차변 전용 → `r_account 잘못된 값입니다`) — 2026-08-03 회귀의 원인.
|
||||
|
||||
if kind == "withdrawal":
|
||||
if rule:
|
||||
@@ -542,6 +653,38 @@ def build_structured(parsed, sender_info, accounts, merchant_map):
|
||||
return None
|
||||
|
||||
|
||||
def _handle_card_cancel(parsed, sender_info, accounts, dry_run=False):
|
||||
"""카드 취소 → 원본 승인 거래를 찾아 취소액만큼 감액.
|
||||
|
||||
반환 (ok, detail). ok=False 면 원본을 특정하지 못한 것이니 호출측이 raw 폴백으로 넘긴다.
|
||||
⚠️ 폴백은 자동 처리가 아니다 — 후잉은 취소 문자를 받아도 원본을 지워주지 않으므로
|
||||
(공식 FAQ 도 환불은 수동 처리하라고 안내한다) 관리자님이 후잉 UI 에서 손봐야 한다.
|
||||
그래서 폴백 건은 raw 폴백 알림에 그대로 실어 보낸다.
|
||||
|
||||
전액취소면 금액이 0 이 되어 줄은 남는다. 후잉 API 로는 거래를 지울 수 없기 때문이다
|
||||
(whooing_balance.api_request 주석 참고). 잔액·합계에는 영향이 없다.
|
||||
"""
|
||||
carrier_acct = accounts.get("carrier_to_account", {}).get(sender_info.get("carrier"), "")
|
||||
if not carrier_acct:
|
||||
return False, "카드 계정 매핑 없음"
|
||||
|
||||
money = parsed["amount"]
|
||||
cands = whooing_balance.find_card_entries(
|
||||
carrier_acct, money, parsed["merchant"], parsed["entry_date"]
|
||||
)
|
||||
if len(cands) != 1:
|
||||
return False, f"원본 승인 후보 {len(cands)}건 — 1건일 때만 감액"
|
||||
|
||||
orig = cands[0]
|
||||
new_money = (orig.get("money") or 0) - money
|
||||
detail = (f"{orig.get('item')} {orig.get('money'):,}원 → {new_money:,}원 "
|
||||
f"(취소 {money:,}원, entry {orig['entry_id']})")
|
||||
if dry_run:
|
||||
return True, f"(dry-run) {detail}"
|
||||
whooing_balance.set_entry_money(orig, new_money)
|
||||
return True, detail
|
||||
|
||||
|
||||
def _parse_iso_utc(iso: str) -> datetime:
|
||||
return datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
||||
|
||||
@@ -611,6 +754,32 @@ def detect_pairs(candidates, accounts):
|
||||
return pairs, used
|
||||
|
||||
|
||||
def try_securities_transfer(parsed, cand, accounts):
|
||||
"""자본 분개로 갈 뻔한 은행 SMS 가 실은 증권 이체인지 확인.
|
||||
|
||||
은행 SMS 에는 입금자명(본인 이름)만 찍혀 목적지를 알 수 없지만, 반대편은
|
||||
키움 kt00015 에 남는다. 짝을 찾으면 자본이 아니라 자산↔자산 이체로 분개한다.
|
||||
|
||||
반환: {payload, key, label} | None (짝 없음·애매·조회 실패)
|
||||
"""
|
||||
if not parsed or parsed.get("kind") not in ("withdrawal", "deposit"):
|
||||
return None
|
||||
bank_asset = accounts.get("carrier_to_account", {}).get(cand["info"].get("carrier"))
|
||||
if not bank_asset:
|
||||
return None # 은행 자산 계정을 모르면 이체를 구성할 수 없다
|
||||
entry_date = parsed.get("entry_date")
|
||||
amount = parsed.get("amount")
|
||||
if not entry_date or not amount:
|
||||
return None
|
||||
match = securities_match.find_match(
|
||||
entry_date, amount, parsed["kind"], cand.get("created_at"))
|
||||
if match is None:
|
||||
return None
|
||||
payload = securities_match.build_payload(
|
||||
match, bank_asset, entry_date, amount, parsed["kind"], parsed.get("balance"))
|
||||
return {"payload": payload, "key": match["key"], "label": match["label"]}
|
||||
|
||||
|
||||
def post_to_whooing(webhook_url, payload, dry_run=False):
|
||||
"""payload는 dict. 반환: (ok: bool, status: int, body: str).
|
||||
HTTP 2xx + body가 'done' 으로 시작해야 성공. 그 외(HTTP 200 + 'fail' 포함) 모두 실패."""
|
||||
@@ -737,12 +906,16 @@ def main():
|
||||
sent_transfer = 0
|
||||
sent_structured = 0
|
||||
sent_raw = 0
|
||||
sent_securities = 0
|
||||
sent_cancel = 0
|
||||
failed = 0
|
||||
new_failures = []
|
||||
new_raw = [] # raw 폴백으로 성공 처리된 건 (구조화 실패 신호) — 텔레그램 알림용
|
||||
recovery_hints: dict[str, str] = {}
|
||||
liability_accounts = set(accounts.get("categories", {}).get("부채", []))
|
||||
liability_payment_offsets_to_add: dict[str, int] = {}
|
||||
capital_accounts = set(accounts.get("categories", {}).get("순자산", []))
|
||||
capital_blocked = [] # 기초잔액(자본) 분개로 갈 뻔한 건 — 후잉에 넣지 않고 알림만
|
||||
latest = last_message_at
|
||||
blocked_at = None # 첫 실패/hold 의 created_at — 이후 메시지는 처리/커서 진행 모두 중단
|
||||
|
||||
@@ -794,8 +967,42 @@ def main():
|
||||
blocked_at = c["created_at"]
|
||||
break
|
||||
|
||||
# 카드 취소는 상쇄 줄을 더하지 않고 원본 승인 거래를 감액한다.
|
||||
# 원본을 못 찾으면 아래 raw 폴백으로 흘러간다(build_structured 가 None 을 준다).
|
||||
if parsed and parsed.get("kind") == "card_cancel":
|
||||
try:
|
||||
cancel_ok, cancel_detail = _handle_card_cancel(
|
||||
parsed, c["info"], accounts, args.dry_run
|
||||
)
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
detail = f"{type(e).__name__}: {e}"
|
||||
new_failures.append({
|
||||
"sender": c["sender"], "label": c["info"]["label"],
|
||||
"created_at": c["created_at"], "text": c["text"],
|
||||
"mode": "cancel_reduce", "payload": {},
|
||||
"status": 0, "response": detail[:500],
|
||||
"failed_at": datetime.now(KST).isoformat(),
|
||||
})
|
||||
print(f" ❌ [cancel] {c['info']['label']} {c['created_at']} | {detail[:120]}")
|
||||
blocked_at = c["created_at"]
|
||||
break
|
||||
if cancel_ok:
|
||||
sent_cancel += 1
|
||||
carrier_key = c["info"].get("carrier")
|
||||
if carrier_key:
|
||||
recovery_hints[carrier_key] = cancel_detail
|
||||
print(f" ➖ [cancel] {c['info']['label']} {c['created_at']} | {cancel_detail}")
|
||||
if c["created_at"] > (latest or ""):
|
||||
latest = c["created_at"]
|
||||
continue
|
||||
print(f" ↩︎ [cancel→raw] {c['info']['label']} {c['created_at']} | {cancel_detail}")
|
||||
|
||||
# card_cancel 이 여기까지 오면 감액 실패 → raw 폴백만 남았다.
|
||||
# apply_overrides 는 match 에 kind 가 없는 룰을 모든 kind 에 적용하므로, 그런 룰이
|
||||
# 추가되면 취소가 좌우 뒤집힌 payload 로 새어나갈 수 있어 여기서 끊는다.
|
||||
structured = None
|
||||
if parsed:
|
||||
if parsed and parsed.get("kind") != "card_cancel":
|
||||
structured = apply_overrides(overrides, parsed, c["info"], accounts, merchant_map, c["created_at"])
|
||||
if structured is None:
|
||||
structured = build_structured(parsed, c["info"], accounts, merchant_map)
|
||||
@@ -809,9 +1016,51 @@ def main():
|
||||
mode = "raw"
|
||||
display = c["text"][:60].replace("\n", " ")
|
||||
|
||||
# ⛔ 기초잔액(자본) 분개는 하지 않는다 (2026-09-07 관리자님 지시). 후잉에 넣지 않고 알림만.
|
||||
# `방효원` 같은 자체이체 룰이 left=기초잔액 을 주는데, 짝이 되는 출금 SMS 가 안 오면
|
||||
# 자본 한쪽만 남아 **순자산이 조용히 부풀려진다**(2026-09-07 카카오뱅크 879,993 실사고 —
|
||||
# 처제 대납금 상환이었는데 입금자명이 "방효원"이라 자체이체 룰에 걸렸다).
|
||||
# ⚠️ 커서는 latest_skip 으로 진행시킨다 — 재시도해도 같은 룰이 같은 결과를 내므로
|
||||
# 여기서 커서를 막으면 이 메시지 뒤의 모든 거래가 영구히 멈춘다.
|
||||
sec_key = None
|
||||
if mode == "structured" and (
|
||||
structured.get("left") in capital_accounts
|
||||
or structured.get("right") in capital_accounts
|
||||
):
|
||||
# 자본으로 갈 뻔한 건이 실은 증권 이체일 수 있다. 은행 SMS 는 입금자명만
|
||||
# 주지만 반대편이 키움 kt00015 에 남으므로, 짝을 찾으면 자본이 아니라
|
||||
# 자산↔자산 이체로 분개한다. 짝이 없으면 기존대로 미반영+알림.
|
||||
sec = try_securities_transfer(parsed, c, accounts)
|
||||
if sec:
|
||||
payload = {k: str(v) for k, v in sec["payload"].items()}
|
||||
mode = "securities"
|
||||
sec_key = sec["key"]
|
||||
display = (f"{sec['payload']['left']} ← {sec['payload']['right']} "
|
||||
f"{sec['payload']['money']:,}원 (증권 {sec['label']} 이체)")
|
||||
else:
|
||||
capital_blocked.append({
|
||||
"label": c["info"]["label"],
|
||||
"created_at": c["created_at"],
|
||||
"payload": payload,
|
||||
"blocked_at": datetime.now(KST).isoformat(),
|
||||
})
|
||||
if c["created_at"] > (latest_skip or ""):
|
||||
latest_skip = c["created_at"]
|
||||
print(f" ⛔ [capital-block] {c['info']['label']} {c['created_at']} | {display} — 미반영, 알림만")
|
||||
continue
|
||||
|
||||
ok, status, body = post_to_whooing(webhook_url, payload, dry_run=args.dry_run)
|
||||
if ok:
|
||||
if mode == "structured":
|
||||
if mode == "securities":
|
||||
sent_securities += 1
|
||||
# POST 성공 후에만 기록한다 — 실패하면 다음에 다시 짝으로 잡혀야 한다.
|
||||
if not args.dry_run:
|
||||
securities_match.mark_matched(sec_key, {
|
||||
"sms_at": c["created_at"],
|
||||
"money": int(payload["money"]),
|
||||
"entry": display,
|
||||
})
|
||||
elif mode == "structured":
|
||||
sent_structured += 1
|
||||
carrier_key = c["info"].get("carrier")
|
||||
if carrier_key:
|
||||
@@ -832,6 +1081,7 @@ def main():
|
||||
sent_raw += 1
|
||||
new_raw.append({
|
||||
"label": c["info"]["label"],
|
||||
"carrier": c["info"].get("carrier"),
|
||||
"created_at": c["created_at"],
|
||||
"text": c["text"],
|
||||
"parsed": parsed,
|
||||
@@ -864,12 +1114,19 @@ def main():
|
||||
f"<i>failures.json 에서 확인 부탁드립니다.</i>"
|
||||
)
|
||||
|
||||
if capital_blocked and not args.dry_run:
|
||||
failures.setdefault("capital_blocked", []).extend(capital_blocked)
|
||||
save_json(FAILURES_FILE, failures)
|
||||
notify.send(_format_capital_blocked(capital_blocked))
|
||||
|
||||
if new_raw and not args.dry_run:
|
||||
notify.send(_format_raw_fallback(new_raw))
|
||||
|
||||
# 잔액 정합성 체크: 부분 처리(blocked_at) 사이클은 skip — 후잉이 모든 거래를 받지 못한 상태에서
|
||||
# 비교하면 잘못된 알림이 발생할 수 있음.
|
||||
if not blocked_at and not args.dry_run:
|
||||
# 이번 사이클에 raw 로 넘어간(=구조화 실패) 계정들. 이자 자동분개 가드에 쓴다.
|
||||
raw_carriers = {r.get("carrier") for r in new_raw if r.get("carrier")}
|
||||
last_balance_by_carrier: dict = {}
|
||||
for c in candidates:
|
||||
parsed = c.get("parsed") or {}
|
||||
@@ -935,16 +1192,32 @@ def main():
|
||||
prev = carriers_state.get(carrier, {})
|
||||
prev_diff = prev.get("last_diff")
|
||||
|
||||
# 카뱅 자동 이자/캐시백 보정: 자산 + 후잉이 SMS보다 적고(deposit 누락), 차이 ≤ 5,000원.
|
||||
# 은행 자동 이자/캐시백 보정: 자산 + 후잉이 SMS보다 적고(deposit 누락), 차이 ≤ 5,000원.
|
||||
# 횟수·날짜 제한 없이 매번 자동 분개. 매 발화마다 텔레그램 알림으로 누적 가시성 확보.
|
||||
# 정상 SMS가 와서 deposit이 후잉에 들어가면 diff>=0이 되어 안전하게 미발화.
|
||||
cur_month = datetime.now(KST).strftime("%Y-%m")
|
||||
if (
|
||||
interest_candidate = (
|
||||
info.get("side") == "asset"
|
||||
and carrier == "kakaobank"
|
||||
and carrier in {"kakaobank", "shinhan_bank", "hana_bank"}
|
||||
and diff < 0
|
||||
and abs(diff) <= 5000
|
||||
):
|
||||
)
|
||||
# ⚠️ 이 사이클에 raw 로 샌 메시지가 있는 계정은 이자로 단정하지 않는다.
|
||||
# 입금 SMS 는 structured 매칭에 실패해 raw 로 넘어가고, 후잉은 200 만 주고
|
||||
# 분개를 만들지 않는다. 그러면 diff 가 그 입금액만큼 음수가 되는데, 이 heuristic 은
|
||||
# 그걸 이자로 오인해 가짜 수익을 원장에 굳혀버린다 (2026-08-14 카카오뱅크 3,700원
|
||||
# 실사고 — 한재성 입금 10,900 미기록이 원인이었고, 진짜 8월 이자는 08/10 에
|
||||
# SMS 로 6,735원이 이미 들어와 있었다). 이 경우 분개 대신 알림만 보낸다.
|
||||
if interest_candidate and carrier in raw_carriers:
|
||||
notify.send(
|
||||
f"🔍 <b>{notify.escape_html(info['label'])} 미기록 입금 의심</b>\n"
|
||||
f"차이 <b>{diff:+,}원</b> · 이 사이클에 raw 폴백이 있어 "
|
||||
f"이자 자동분개를 보류했어요.\n"
|
||||
f"<i>입금 SMS 가 후잉에 안 들어갔을 수 있어요. 확인 부탁드려요.</i>"
|
||||
)
|
||||
print(f" 🔍 [interest-hold] {info['label']} diff={diff:+,} — raw 폴백 존재로 이자 분개 보류")
|
||||
interest_candidate = False
|
||||
if interest_candidate:
|
||||
interest_amount = -diff
|
||||
interest_payload = {
|
||||
"entry_date": datetime.now(KST).strftime("%Y%m%d"),
|
||||
@@ -957,7 +1230,7 @@ def main():
|
||||
ok, status, body = post_to_whooing(webhook_url, interest_payload, dry_run=False)
|
||||
if ok:
|
||||
notify.send(
|
||||
f"💰 <b>카뱅 은행이자 자동 분개</b>\n"
|
||||
f"💰 <b>{notify.escape_html(info['label'])} 은행이자 자동 분개</b>\n"
|
||||
f"+{interest_amount:,}원 ({notify.escape_html(info['account'])} ← 기타수익)"
|
||||
)
|
||||
carriers_state[carrier] = {
|
||||
@@ -1080,6 +1353,14 @@ def main():
|
||||
changed = True
|
||||
print(f" 📊 [balance-init] {info['label']} sms={sms_b:,} whoo={whoo_b:,} diff={diff:+,} (notify skip)")
|
||||
continue
|
||||
# 카드 누적사용액이 후잉보다 크면 = 우리가 못 받은 승인이 있다.
|
||||
# 그 구간과 금액을 누적 연속성으로 짚어 알림에 붙인다(조회는 이때만).
|
||||
gaps = None
|
||||
if info.get("side") == "liability" and diff < 0:
|
||||
try:
|
||||
gaps = _cumulative_gap_scan(info["account"])
|
||||
except Exception as e:
|
||||
print(f" ⚠️ [cum-gap] {info['label']} 누적 연속성 검사 실패: {e}")
|
||||
notify.send(_format_balance_mismatch(
|
||||
{
|
||||
**info,
|
||||
@@ -1087,7 +1368,7 @@ def main():
|
||||
"liability_payment_offset": liability_payment_offset,
|
||||
},
|
||||
sms_b, whoo_b, diff, prev_diff,
|
||||
recovery_hints.get(carrier),
|
||||
recovery_hints.get(carrier), gaps,
|
||||
))
|
||||
carriers_state[carrier] = {
|
||||
"account": info["account"],
|
||||
@@ -1121,9 +1402,9 @@ def main():
|
||||
save_json(SYNC_STATE_FILE, sync_state)
|
||||
|
||||
if blocked_at:
|
||||
print(f"⚠️ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 실패 {failed}건 — {blocked_at} 에서 중단(다음 cron 재시도)")
|
||||
print(f"⚠️ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, 증권이체 {sent_securities}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 — {blocked_at} 에서 중단(다음 cron 재시도)")
|
||||
else:
|
||||
print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, raw {sent_raw}건, 실패 {failed}건 (last={final_latest or latest})")
|
||||
print(f"✅ 후잉 동기화: transfer {sent_transfer}건, structured {sent_structured}건, 증권이체 {sent_securities}건, raw {sent_raw}건, 취소감액 {sent_cancel}건, 실패 {failed}건 (last={final_latest or latest})")
|
||||
|
||||
# 가희 잔액 리마인더 & 답신 자동 분개 (격리 — 실패해도 결제 sync 결과는 위에서 이미 출력됨)
|
||||
run_gahee_reminder()
|
||||
|
||||
BIN
Binary file not shown.
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"version": 1,
|
||||
"bootstrapSeededAt": "2026-04-17T03:21:06.738Z",
|
||||
"setupCompletedAt": "2026-04-17T05:20:30.244Z"
|
||||
}
|
||||
@@ -95,6 +95,7 @@ LLM-LLM 자연어 통신은 프롬프트 인젝션·할루시네이션 증폭
|
||||
- **키움 조회:** `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 조회 전용 (주문 X)
|
||||
- **일일 포트폴리오:** `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 평일 20:10 launchd, 메일만 발송(텔레그램 요약 없음)
|
||||
- **IPO 캘린더:** `scripts/ipo_calendar_sync.py` — 매월 1일 cron
|
||||
- **IPO 텔레그램 알림:** `scripts/ipo_alert.py` — 매일 08:30 상장일, 10:00 청약 첫날만 알림. 증권사별 최소청약 증거금·온라인 일반 수수료 표시(공모가·증거금률은 숨김), `state/ipo_alerts.json`으로 중복 방지. 상장일(08:30 listing) 실행 때 자산웹에서 **청약완료한 종목은 관심종목에 자동등록**(종목당 1회)
|
||||
- **비하이브 다이제스트:** `scripts/behive_youtube_digest.py {fetch|save|email|notify|list|show|remove}` — 평일 07/12/18시 cron
|
||||
- **워치리스트 모니터:** `scripts/watchlist_monitor.py check` — 장중 15분, LLM 없이 직접 알림
|
||||
- **워치리스트 웹뷰:** `scripts/behive_web.py serve` — launchd 상시, 외부는 `https://stock.hyowons.net/`
|
||||
@@ -110,3 +111,65 @@ LLM-LLM 자연어 통신은 프롬프트 인젝션·할루시네이션 증폭
|
||||
## 직접 고쳐 써라
|
||||
|
||||
이 문서는 출발점이다. 패턴이 보이면 직접 업데이트해라. 정비는 코디가 하지만, 이 매뉴얼의 작가는 너다.
|
||||
|
||||
## Tools
|
||||
|
||||
### Local notes (migrated from TOOLS.md)
|
||||
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
환경 세팅 메모. 관리자님 자산 관련 파일과 명령어 위치.
|
||||
|
||||
## Stock Data
|
||||
|
||||
모든 경로는 `agents/stock/workspace/` 기준.
|
||||
|
||||
- `memory/portfolio.json` — 보유 종목 **v2 스키마 스냅샷** (`accounts.{일반,ISA,가희_일반,가희_ISA}.positions`). 참고용. 실제 리포트 숫자는 항상 키움 REST 실시간 조회.
|
||||
- `state/portfolio_daily_snapshot.json` — 일별 owner-그룹 스냅샷. v4 스키마: `{date: {owner: {holdings: {stock:{...}}, deposit: int, trade_journal: [...], realized_pl_total: int, realized_fees_total: int}}}`. 전날대비 순자산(평가+예수금) + 월별 실현손익/회전율/수수료 통계용. `stock_portfolio_report.py`가 작성. legacy v1/v2/v3 호환.
|
||||
- `state/kiwoom_tokens/{일반,ISA,가희_일반,가희_ISA}.json` — 키움 REST OAuth 토큰 캐시 (계좌별 분리). `expires_dt` 기준 자동 갱신.
|
||||
- `state/ipo_calendar_sync.json` — 공모주 캘린더 동기화 상태.
|
||||
- `state/behive_watchlist.json` — **비하이브 종목분석 워치리스트**. 관리자님이 "OO 목표가 얼마야?" 같은 비하이브 분석 관련 질문하면 `skills/behive-watchlist` 사용. 종목별 목표/매입/손절/요약/출처영상 포함.
|
||||
- `state/behive_youtube_seen.json` / `state/behive_last_fetch.json` — 비하이브 스크립트 내부 상태.
|
||||
- `/Users/snowoyh/.openclaw/credentials/kiwoom.json` — 키움 자격증명 (계좌별 AppKey/SecretKey 분리 v2 스키마). 예제: `kiwoom.json.example`.
|
||||
|
||||
## Scripts (python3)
|
||||
|
||||
- `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 키움 REST 조회 전용 클라이언트. 잔고/보유/계좌평가. **주문 함수 절대 부재**(매매 절대 원칙, SOUL.md 참조). 자세한 사용법은 `skills/kiwoom-rest`.
|
||||
- `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 리포트 생성/이메일. 평일 20:10 launchd로 `send`. owner 그룹(본인 / 가희)별 블록 표시가 기본 (그룹 내 종목 합산), `--by-account`는 owner 블록 안에서 4계좌별 분리 뷰까지.
|
||||
- `scripts/ipo_calendar_sync.py` — 공모주 일정 구글 캘린더 등록. 매월 1일 cron.
|
||||
- `scripts/ipo_alert.py {subscription|listing}` — 청약 첫날 10:00·상장일 08:30 텔레그램 알림. `--dry-run`, `--date YYYY-MM-DD` 지원.
|
||||
- `scripts/behive_youtube_digest.py {fetch|save|email|notify|list|show|remove}` — 비하이브투자자문 '종목분석' 영상 감지·요약·워치리스트 저장·이메일/텔레그램 발송. 평일 07/12/18시 cron.
|
||||
|
||||
## 관리자님 관련
|
||||
|
||||
- 잔고·보유·수익률 질문이 들어오면 **키움 REST로 실시간 조회**. `memory/portfolio.json`은 스냅샷이라 stale 할 수 있음.
|
||||
- 이메일 수신자: `mini.snowoyh@gmail.com` (리포트 발송 대상)
|
||||
|
||||
## 증권사
|
||||
|
||||
- 키움증권. iMessage 체결통보 파싱은 2026-04-24 REST API 연동으로 폐기됨 — 모든 체결·잔고 정보는 `kiwoom_client.py`로 조회.
|
||||
|
||||
## Playwright MCP (브라우저 자동화)
|
||||
|
||||
OpenClaw가 `openclaw.json` 의 MCP 서버 `playwright`로 노출. 도구는 `playwright__browser_*` prefix로 약 23개.
|
||||
|
||||
- **탐색:** `navigate / navigate_back / tabs / wait_for`
|
||||
- **관찰:** `snapshot` (접근성 트리 — 클릭 대상 ref 식별에 **우선 사용**) / `take_screenshot` / `console_messages` / `network_requests` / `network_request`
|
||||
- **조작:** `click / type / fill_form / press_key / hover / select_option / drag / drop / file_upload / handle_dialog / resize / close`
|
||||
- **평가:** `evaluate` (페이지 JS 컨텍스트) / `run_code_unsafe` (Playwright 코드 직접 — **RCE 등급, 마지막 수단**)
|
||||
|
||||
**표준 흐름:** `navigate` → `snapshot`으로 ref 확보 → `click`/`type`/`fill_form` → `wait_for`로 결과 대기 → `snapshot` 또는 `network_requests`로 검증. 스크린샷은 사람 보고용이지 다음 동작 분기 근거로 쓰지 말 것.
|
||||
|
||||
**운영 특성:**
|
||||
- 기본값 `--headless --isolated`. 매 세션 쿠키·로그인 폐기.
|
||||
- 영속 로그인 필요한 사이트는 시도 전 **코디에게 `user-data-dir` 분리·등록 요청**.
|
||||
- 첫 호출 시 npx spawn 1~2초 지연.
|
||||
- **키움 영웅문·HTS는 보안프로그램이 Playwright 탐지·차단할 가능성 큼 → 시도 자제**. 잔고·시세는 항상 `kiwoom_client.py` REST 우선.
|
||||
- 공동인증서·OTP·CAPTCHA 화면이 나오면 **즉시 중단하고 관리자님께 보고**, 우회 시도 X.
|
||||
|
||||
**활용 시나리오 (레이 맥락):**
|
||||
- DART 공시 검색·본문 추출·PDF 다운로드 (정정·합병·유상증자 키워드 모니터링)
|
||||
- 네이버 증권 외국인·기관 매매동향 등 키움 REST에 없는 보조 지표 스크래핑
|
||||
- 비하이브 페이지 보조(댓글·공지 등 유튜브 자막 외 정보) — 기존 `behive_youtube_digest`와 상호 보완
|
||||
- 종목별 공시·뉴스 변화 감지 → 워치리스트 알림 보강 (LLM 미경유 모니터에 결과만 넘김)
|
||||
- 매매(주문) 흐름에는 **절대 사용 금지** — 주문은 `orders/kiwoom_order.py` REST 한 경로만 (SOUL.md 매매 원칙 참조)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -33,13 +33,16 @@ Be the assistant you'd actually want to talk to. Concise when needed, thorough w
|
||||
|
||||
레이는 **매매를 결정하지 않는다.** 결정권은 항상 관리자님 PIN echo 에 있다.
|
||||
|
||||
- **LLM 자율 매매 금지** — 워치리스트 도달·시장 이벤트·뉴스·자체 추론으로 매수/매도 자동 트리거 X. 매매는 오직 관리자님이 텔레그램에 직접 명시적으로 지시할 때만.
|
||||
- **LLM 자율 매매 금지** — 워치리스트 도달·시장 이벤트·뉴스·자체 추론으로 매수/매도 자동 트리거 X. 매매는 오직 관리자님이 텔레그램에 직접 명시적으로 지시할 때만. (가격 도달로 주문이 나가는 경로가 하나 있다 — 자산웹에서 PIN 으로 사전 승인한 **예약 트레일링**뿐이고, 아래 「자동 트리거 등록 금지」의 예외 항목을 볼 것. 레이가 만들 수 있는 경로는 아니다.)
|
||||
- **레이의 역할 = 페이로드 추출** — 자연어("삼성 5주 75,000 원에 사줘") → 종목코드·계좌·수량·가격·주문방식을 dict 로 정리해서 `orders.handler.propose_trade` 진입점에 전달. 그 외엔 일체 매매 도구를 부르지 않는다.
|
||||
- **카드+PIN 분리 발송** — propose_trade 가 카드 메시지(매수/매도·계좌·종목·가격 4개 하이라이트) + PIN 메시지(단독 4자리 숫자, 가희 계좌면 8자리 영숫자) 두 개를 텔레그램에 보낸다.
|
||||
- **PIN echo 가 마지막 게이트** — 관리자님이 PIN 을 그대로 답장한 순간만 실주문이 나간다. 레이 LLM 이 PIN 을 자기 판단으로 만들거나 변형하면 절대 안 된다.
|
||||
- **사이드카 ON 상태에선 모든 매매 거부** — `state/orders_disabled` 파일 존재 시 진입점 첫 줄에서 차단. `/orders_off` `/orders_on` 으로 토글.
|
||||
- **조회 vs 주문 분리 유지** — `kiwoom_client.py` 는 여전히 조회 전용. 주문 함수는 `orders/` 패키지로 분리. 조회 모듈에 주문 함수 추가 금지.
|
||||
- **매매 관련 자동 트리거 등록 금지** — 매매·매매재시도·취소·재발행 등 `orders/` 모듈 진입점을 cron `jobs.json` 또는 launchd plist 에 자동 발화 항목으로 등록하지 않는다. 실패·거절·쿨다운된 매매는 관리자님이 텔레그램으로 다시 명시적으로 지시할 때까지 그대로 둔다. "쿨다운 끝나면 다시 시도하자"는 충동도 X — 자동 재시도 등록 자체가 LLM 자율 매매와 동급. 자동화가 필요해 보이면 직접 등록하지 말고 관리자님께 설계 안건으로 제안.
|
||||
- **승인된 예외는 `ai.openclaw.stock.trailing-monitor.plist` 하나뿐**이고, 그 안에서 허용된 매매 API 는 ①`modify_order`(트레일링 손절선 **상향** 정정, 2026-07-30 승인) ②**예약 트레일링(TRAILING_ARM)의 발동가 도달 시 스톱주문 발주**(2026-08-25 승인) 2종이다.
|
||||
- 허용 근거는 "발주가 없다"가 **아니라** "계좌·종목·수량·계단·발동가가 **PIN 승인 시점에 전부 확정**되고, 감시는 확정된 내용을 조건 도달 시 집행할 뿐 LLM 판단이 개입하지 않는다" 다. 판단 기준을 이렇게 읽어야 한다 — **누가 매매의 *내용*(계좌·종목·수량·방향·가격)을 정하는가.** 사람이 PIN 으로 정했으면 집행 자동화는 별건 승인 대상, 감시 루프나 LLM 이 정하면 금지.
|
||||
- 이 예외를 근거로 **새 자동 트리거를 스스로 늘리지 않는다.** 감시가 수량을 늘리거나 방향을 바꾸는 경로도 여전히 없다(매도가능 부족 시 **축소**만).
|
||||
- **자동매매·알고리즘 매매 요청** — 이 원칙을 환기하고 사이드카·1주 검증·가드 보강을 거친 별도 설계 안건으로 미룬다.
|
||||
|
||||
상세 사양과 호출 진입점은 `orders/__init__.py`, 한도값은 `orders/limits.json` 참조. 사용은 `skills/order-trading`, `skills/order-controls`.
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
# TOOLS.md - Local Notes
|
||||
|
||||
환경 세팅 메모. 관리자님 자산 관련 파일과 명령어 위치.
|
||||
|
||||
## Stock Data
|
||||
|
||||
모든 경로는 `agents/stock/workspace/` 기준.
|
||||
|
||||
- `memory/portfolio.json` — 보유 종목 **v2 스키마 스냅샷** (`accounts.{일반,ISA,가희_일반,가희_ISA}.positions`). 참고용. 실제 리포트 숫자는 항상 키움 REST 실시간 조회.
|
||||
- `state/portfolio_daily_snapshot.json` — 일별 owner-그룹 스냅샷. v4 스키마: `{date: {owner: {holdings: {stock:{...}}, deposit: int, trade_journal: [...], realized_pl_total: int, realized_fees_total: int}}}`. 전날대비 순자산(평가+예수금) + 월별 실현손익/회전율/수수료 통계용. `stock_portfolio_report.py`가 작성. legacy v1/v2/v3 호환.
|
||||
- `state/kiwoom_tokens/{일반,ISA,가희_일반,가희_ISA}.json` — 키움 REST OAuth 토큰 캐시 (계좌별 분리). `expires_dt` 기준 자동 갱신.
|
||||
- `state/ipo_calendar_sync.json` — 공모주 캘린더 동기화 상태.
|
||||
- `state/behive_watchlist.json` — **비하이브 종목분석 워치리스트**. 관리자님이 "OO 목표가 얼마야?" 같은 비하이브 분석 관련 질문하면 `skills/behive-watchlist` 사용. 종목별 목표/매입/손절/요약/출처영상 포함.
|
||||
- `state/behive_youtube_seen.json` / `state/behive_last_fetch.json` — 비하이브 스크립트 내부 상태.
|
||||
- `/Users/snowoyh/.openclaw/credentials/kiwoom.json` — 키움 자격증명 (계좌별 AppKey/SecretKey 분리 v2 스키마). 예제: `kiwoom.json.example`.
|
||||
|
||||
## Scripts (python3)
|
||||
|
||||
- `scripts/kiwoom_client.py {token|accounts|summary|balance|positions}` — 키움 REST 조회 전용 클라이언트. 잔고/보유/계좌평가. **주문 함수 절대 부재**(매매 절대 원칙, SOUL.md 참조). 자세한 사용법은 `skills/kiwoom-rest`.
|
||||
- `scripts/stock_portfolio_report.py {run|send} [--by-account]` — 리포트 생성/이메일. 평일 20:10 launchd로 `send`. owner 그룹(본인 / 가희)별 블록 표시가 기본 (그룹 내 종목 합산), `--by-account`는 owner 블록 안에서 4계좌별 분리 뷰까지.
|
||||
- `scripts/ipo_calendar_sync.py` — 공모주 일정 구글 캘린더 등록. 매월 1일 cron.
|
||||
- `scripts/behive_youtube_digest.py {fetch|save|email|notify|list|show|remove}` — 비하이브투자자문 '종목분석' 영상 감지·요약·워치리스트 저장·이메일/텔레그램 발송. 평일 07/12/18시 cron.
|
||||
|
||||
## 관리자님 관련
|
||||
|
||||
- 잔고·보유·수익률 질문이 들어오면 **키움 REST로 실시간 조회**. `memory/portfolio.json`은 스냅샷이라 stale 할 수 있음.
|
||||
- 이메일 수신자: `mini.snowoyh@gmail.com` (리포트 발송 대상)
|
||||
|
||||
## 증권사
|
||||
|
||||
- 키움증권. iMessage 체결통보 파싱은 2026-04-24 REST API 연동으로 폐기됨 — 모든 체결·잔고 정보는 `kiwoom_client.py`로 조회.
|
||||
|
||||
## Playwright MCP (브라우저 자동화)
|
||||
|
||||
OpenClaw가 `openclaw.json` 의 MCP 서버 `playwright`로 노출. 도구는 `playwright__browser_*` prefix로 약 23개.
|
||||
|
||||
- **탐색:** `navigate / navigate_back / tabs / wait_for`
|
||||
- **관찰:** `snapshot` (접근성 트리 — 클릭 대상 ref 식별에 **우선 사용**) / `take_screenshot` / `console_messages` / `network_requests` / `network_request`
|
||||
- **조작:** `click / type / fill_form / press_key / hover / select_option / drag / drop / file_upload / handle_dialog / resize / close`
|
||||
- **평가:** `evaluate` (페이지 JS 컨텍스트) / `run_code_unsafe` (Playwright 코드 직접 — **RCE 등급, 마지막 수단**)
|
||||
|
||||
**표준 흐름:** `navigate` → `snapshot`으로 ref 확보 → `click`/`type`/`fill_form` → `wait_for`로 결과 대기 → `snapshot` 또는 `network_requests`로 검증. 스크린샷은 사람 보고용이지 다음 동작 분기 근거로 쓰지 말 것.
|
||||
|
||||
**운영 특성:**
|
||||
- 기본값 `--headless --isolated`. 매 세션 쿠키·로그인 폐기.
|
||||
- 영속 로그인 필요한 사이트는 시도 전 **코디에게 `user-data-dir` 분리·등록 요청**.
|
||||
- 첫 호출 시 npx spawn 1~2초 지연.
|
||||
- **키움 영웅문·HTS는 보안프로그램이 Playwright 탐지·차단할 가능성 큼 → 시도 자제**. 잔고·시세는 항상 `kiwoom_client.py` REST 우선.
|
||||
- 공동인증서·OTP·CAPTCHA 화면이 나오면 **즉시 중단하고 관리자님께 보고**, 우회 시도 X.
|
||||
|
||||
**활용 시나리오 (레이 맥락):**
|
||||
- DART 공시 검색·본문 추출·PDF 다운로드 (정정·합병·유상증자 키워드 모니터링)
|
||||
- 네이버 증권 외국인·기관 매매동향 등 키움 REST에 없는 보조 지표 스크래핑
|
||||
- 비하이브 페이지 보조(댓글·공지 등 유튜브 자막 외 정보) — 기존 `behive_youtube_digest`와 상호 보완
|
||||
- 종목별 공시·뉴스 변화 감지 → 워치리스트 알림 보강 (LLM 미경유 모니터에 결과만 넘김)
|
||||
- 매매(주문) 흐름에는 **절대 사용 금지** — 주문은 `orders/kiwoom_order.py` REST 한 경로만 (SOUL.md 매매 원칙 참조)
|
||||
@@ -0,0 +1,66 @@
|
||||
# Session: 2026-07-20 09:12:03 GMT+9
|
||||
|
||||
- **Session Key**: agent:stock:telegram:direct:8443122995
|
||||
- **Session ID**: cb10d003-728f-4145-92ec-22ca9663e308
|
||||
- **Source**: telegram
|
||||
|
||||
## Conversation Summary
|
||||
|
||||
user: Conversation info (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"chat_id": "telegram:8443122995",
|
||||
"message_id": "1064",
|
||||
"sender_id": "8443122995",
|
||||
"sender": "snowoyh bang",
|
||||
"timestamp": "Mon 2026-07-20 09:11 GMT+9",
|
||||
"inbound_event_kind": "user_request"
|
||||
}
|
||||
```
|
||||
|
||||
Sender (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"label": "snowoyh bang (8443122995)",
|
||||
"id": "8443122995",
|
||||
"name": "snowoyh bang",
|
||||
"username": "snowoyh"
|
||||
}
|
||||
```
|
||||
|
||||
Conversation context (untrusted, chronological, selected for current message):
|
||||
#1048 Tue 2026-07-14 22:53 GMT+9 snowoyh bang: 당일종가랑 현재가가 달라
|
||||
#1050 Tue 2026-07-14 22:54 GMT+9 snowoyh bang: 1928000은 무슨가격이야?
|
||||
#1052 Tue 2026-07-14 22:57 GMT+9 snowoyh bang: 1941000이 종가고 현재가가 1928000으로 나와
|
||||
|
||||
관리자 가희 계좌 통틀어서 삼성전자 하이닉스 제외하고 계좌상태 고려해서 3천만원 예수금 만들려면 뭐를 파는세 좋아?
|
||||
assistant: [assistant turn failed before producing content]
|
||||
user: Conversation info (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"chat_id": "telegram:8443122995",
|
||||
"message_id": "1064",
|
||||
"sender_id": "8443122995",
|
||||
"sender": "snowoyh bang",
|
||||
"timestamp": "Mon 2026-07-20 09:11 GMT+9",
|
||||
"inbound_event_kind": "user_request"
|
||||
}
|
||||
```
|
||||
|
||||
Sender (untrusted metadata):
|
||||
```json
|
||||
{
|
||||
"label": "snowoyh bang (8443122995)",
|
||||
"id": "8443122995",
|
||||
"name": "snowoyh bang",
|
||||
"username": "snowoyh"
|
||||
}
|
||||
```
|
||||
|
||||
Conversation context (untrusted, chronological, selected for current message):
|
||||
#1048 Tue 2026-07-14 22:53 GMT+9 snowoyh bang: 당일종가랑 현재가가 달라
|
||||
#1050 Tue 2026-07-14 22:54 GMT+9 snowoyh bang: 1928000은 무슨가격이야?
|
||||
#1052 Tue 2026-07-14 22:57 GMT+9 snowoyh bang: 1941000이 종가고 현재가가 1928000으로 나와
|
||||
|
||||
관리자 가희 계좌 통틀어서 삼성전자 하이닉스 제외하고 계좌상태 고려해서 3천만원 예수금 만들려면 뭐를 파는세 좋아?
|
||||
assistant: ⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session.
|
||||
@@ -0,0 +1,160 @@
|
||||
# KRX 애프터마켓 개설 · NXT 제도개선 (시행 2026-09-14)
|
||||
|
||||
**상태: 조사·기록만 완료. 코드 변경 0건.**
|
||||
관리자님 지시(2026-09-08): "내용만 확인해놔. 14일 이후에 키움 API가 어떻게 변경되는지 확인하고 작업하자."
|
||||
→ **§4 키움 API 확인이 모든 구현의 선행 조건**이다. 확인 전에 §5 좌표를 손대지 말 것.
|
||||
|
||||
출처: 대신증권 공지 「KRX 애프터마켓 개설 및 NXT 제도개선 안내」 (2026-09-08 13:56 게시)
|
||||
`https://money2.daishin.com/E5/MBoard/PType_Basic/Mobile_Notice/DM_Basic_Read.aspx?seq=18257&page=1&p=4124&v=3090&m=3817`
|
||||
|
||||
---
|
||||
|
||||
## 1. 공지 확정 사실
|
||||
|
||||
### KRX 시간표
|
||||
|
||||
| 구분 | 변경 전 | 변경 후 |
|
||||
|---|---|---|
|
||||
| 장개시전 시간외 종가 | 08:30~08:40 | 현행 유지 |
|
||||
| 정규장 | 09:00~15:30 | 현행 유지 |
|
||||
| 장종료후 시간외 **종가** | 15:40~16:00 | 현행 유지 |
|
||||
| 장종료후 시간외 **단일가** | 16:00~18:00 | **폐지** |
|
||||
| **KRX 애프터마켓** | 없음 | **16:00~20:00 신설** |
|
||||
|
||||
### 애프터마켓 매매 규칙
|
||||
|
||||
- **체결**: 접속매매 (최초/최종 단일가 없음). **VI 발동 시에만** 단일가 전환
|
||||
- ※ NXT 애프터마켓 개장 전 10분간(15:30~15:40)은 단일가매매 적용
|
||||
- **제출가능 호가**: 지정가 · 최우선지정가 · 최유리지정가 (IOC/FOK 허용) → **시장가 없음, 스톱지정가 없음**
|
||||
- **대상**: 유가증권·코스닥의 주권/외국주권/DR/리츠/선박투자회사/투자회사/SOC
|
||||
- **제외종목 9종**: 30분 단위매매 · 거래정지 · 당일 정규장 미거래 · 투자경고 · 투자위험 · 이상급등 · 관리 · 초저유동 · 투자주의환기 · 투자유의
|
||||
- **기존 시간외단일가에서 거래되던 ETF/ETN 거래불가**
|
||||
- 시세: 애프터마켓의 고가/저가/실시간 체결가 산출. **익일 기준가 참조가격은 정규장 종가**
|
||||
|
||||
### 정규장 미체결 주문 — 자동 소멸 시점 (명문화됨)
|
||||
|
||||
| 구분 | KRX | NXT |
|
||||
|---|---|---|
|
||||
| 자동 증거금 해지 | **정규장 종료 후 15:35~** | 애프터마켓 종료 후 20:05~ |
|
||||
| 미체결 주문 | **애프터마켓에서 재주문 필요** (미이전) | 애프터마켓까지 주문 유지 |
|
||||
| 예시 | 09:00 주문 → 15:30 미체결 → 15:35~ 증거금 해지 → 16:00 이후 재주문 | 08:00 주문 → 20:00까지 미체결 → 20:00 이후 증거금 해지 |
|
||||
|
||||
### NXT 제도개선
|
||||
|
||||
**정적VI 도입** + 동적/정적VI 발동 시 단일가매매(2분) 전환
|
||||
|
||||
| | 동적VI | 정적VI |
|
||||
|---|---|---|
|
||||
| 참조가격 | 직전 체결가격 | 직전 단일가매매 가격 (없으면 기준가격) |
|
||||
| 발동요건 | 코스피200 ±3% / 기타 ±6% | ±10% |
|
||||
| 발동효과 | 단일가매매 전환(2분) | 〃 |
|
||||
| 제출가능호가 | 지정가, 스톱지정가호가(메인마켓) | 〃 |
|
||||
| 적용대상 | 모든 접속매매 체결 / 마켓별 최초가격 **배제** | 모든 접속·단일가매매 체결(VI단일가 제외) / 마켓별 최초가격에도 **적용** |
|
||||
|
||||
- **거래정지 후 재개 시 단일가매매**: CB(시장) NXT 30분 중단 + 단일가 30초 *(KRX 20분 + 10분)* / 중요내용공시(종목) NXT 40분 정지 + 단일가 30초 *(KRX 30분 + 10분)*
|
||||
- **GTP(Good till Premarket) 전용호가 도입** — 프리마켓 제출 호가 전체에 적용 가능, 미체결잔량은 **프리마켓 종료(08:50) 일괄 취소**
|
||||
- **프리마켓 상·하한가 주문가능** — 2026-08-12부터 한시 적용됐던 NXT 프리마켓 상·하한가 주문제한 **해제**
|
||||
- **대주거래 09:00~20:00 확대** (KRX/NXT 동일) — 우리는 대주거래 미사용, 영향 없음
|
||||
|
||||
**외부 교차 확인** (뉴스): 애프터마켓 16:00~20:00 · 15:40~16:00 시간외종가 유지 · ETF·ETN 거래불가 · **공식 종가는 15:30 정규장 종가** · 사이드카 없음·VI 있음.
|
||||
|
||||
---
|
||||
|
||||
## 2. 현재 코드가 믿고 있는 시간 모델
|
||||
|
||||
시간 경계는 **`scripts/orders/limits.json` L37-49가 단일 진실 공급원**이고 `guards.py`엔 시간 리터럴이 0개다(`orders/__init__.py:22-24` 설계 규칙).
|
||||
|
||||
`guards.session_at()` (`guards.py:78-89`) 세션 **5종** + `determine_routing()` (`guards.py:143-161`):
|
||||
|
||||
| 세션 | 구간 | suffix | `dmst_stex_tp` |
|
||||
|---|---|---|---|
|
||||
| `NXT_PRE` | 08:00:00–09:00:00 | `_NX` | NXT |
|
||||
| *(틈)* | 09:00:00–09:00:30 | — | **ValueError** |
|
||||
| `KRX_NXT` | 09:00:30–15:20:00 | `_AL` / `''` | SOR / KRX |
|
||||
| `KRX_CLOSE` | 15:20:00–15:30:00 | `''` | KRX |
|
||||
| `NXT_AFTER` | **15:30:00–20:00:00** | `_NX` | NXT |
|
||||
| `CLOSED` | 그 외 | — | **ValueError** |
|
||||
|
||||
⚠️ **핵심: KRX 시간외 시장(종가·단일가)이 모델에 아예 없다. 15:30 이후는 전부 NXT로 취급한다.** KRX 애프터마켓 신설이 정확히 이 가정을 깬다.
|
||||
|
||||
⚠️ 자산웹 phase는 **별개의 하드코딩**이다(`behive_web.py:788-808`) — `09:00~15:30`=`regular`, `08:00~20:00`=`nxt`, 그 외 `closed`. **프리(08:00~09:00)와 애프터(15:30~20:00)를 같은 `nxt` 값으로 구분하지 못한다.**
|
||||
|
||||
---
|
||||
|
||||
## 3. 영향 분석 (2026-09-08 실측)
|
||||
|
||||
### ① 거래 가능해지는데 자산웹이 막는 종목 — 가장 실질적
|
||||
|
||||
ETF/ETN은 `state/stock_codes.json` 기준 **1,063/1,064가 `nxt_enable=False`**. 애프터마켓에서도 ETF/ETN은 거래불가이므로 **현행 차단이 결과적으로 정당**하다.
|
||||
|
||||
문제는 **NXT 미상장 일반주**. 전체 4,300종목 중 `nxt_enable=False`가 3,693개, ETF/ETN을 뺀 약 **2,630개**가 9/14부터 KRX 애프터마켓에서 거래 가능해진다.
|
||||
|
||||
| | ETF/ETN (차단 유지 정당) | **NXT 미상장 일반주 (신규 거래가능)** | NXT 상장 |
|
||||
|---|---|---|---|
|
||||
| 보유 37 | 12 | **15** | 10 |
|
||||
| 관심 57 | 12 | **20** | 25 |
|
||||
| 감시 45 | 0 | **32** | 13 |
|
||||
|
||||
보유분 해당 15종목: LG씨엔에스 · SK이터닉스 · 삼성전자우 · 파두 · 아난티 · 제닉스로보틱스 · 마키나락스 · 노루페인트 · 베뉴지 · 서부T&D · 진흥기업 · 피앤씨테크 · RF머트리얼즈 · WON 두산그룹포커스 · WON 초대형IB&금융지주
|
||||
|
||||
차단 경로: `_market_phase_state()`가 16:00~20:00을 `nxt`로 판정 → `/api/quote_book`이 `nxt_enable=False` 회신(`behive_web.py:14687-14717`) → 클라이언트가 `📵 NXT 거래불가` 라벨 + `cls='closed'`(`behive_web.py:12853`) → `refreshSubmitGate()`가 `state.phaseCanTrade===false`로 매수/매도 버튼 disable(`behive_web.py:12928-12935`).
|
||||
|
||||
### ② 감시 공백
|
||||
|
||||
| 감시 | 현재 커버 | 애프터마켓(16:00~20:00) |
|
||||
|---|---|---|
|
||||
| `trailing_monitor` | 08:00–19:59 (plist 720엔트리) | ✅ 이미 커버 |
|
||||
| `surge_monitor` | 09:00–15:35 (`MARKET_CLOSE=(15,35)` L91, plist 396엔트리) | ❌ 급등락·VI 알림 0건 |
|
||||
| `watchlist_monitor` | 10:00/12:00/14:00 3회 (`MARKET_CLOSE=(15,45)` L48) | ❌ |
|
||||
| `sim-scan` | 09:00–15:30 (plist 196엔트리) | ❌ |
|
||||
|
||||
### ③ 부수 영향 지점
|
||||
|
||||
- **`surge_monitor.get_limit_map()` 하루 1콜 캐시**(L306-320) — 애프터마켓 가격제한폭이 정규장과 다르면 16:00 이후 `upl_pric`/`lst_pric`이 틀린 값이 된다. 공지·뉴스에 제한폭 언급이 없어 **미확정**(§4-4).
|
||||
- **`trailing_monitor.py:142` 실측 전제 재검증 필요** — "스톱주문은 장 마감 후 체결 없이 소멸한다(15:30~18:21 사이)". 그 관측 구간이었던 시간외단일가(16:00~18:00)가 **폐지**되고, 공지가 소멸 시점을 KRX 15:35~ / NXT 20:05~로 명문화했다. 재실측 없이는 `_notify_gone_batch`의 "장 마감 취소 추정" 문구가 틀린 시각에 나간다.
|
||||
- **시장가 차단은 유지가 맞다** — `validate_market_order_session()`(`guards.py:195-205`)이 `NXT_PRE`/`NXT_AFTER`에서 MARKET을 거부한다. 애프터마켓 호가유형에도 시장가가 없어 근거가 오히려 늘어난다.
|
||||
- ⚠️ **`STOP_LIMIT`은 세션 제약이 전무하다.** 애프터마켓엔 스톱지정가가 없고 NXT 메인마켓엔 있으므로, **라우팅을 KRX로 바꾸는 순간 트레일링·스톱 발주가 깨진다.**
|
||||
- **09:00:00–09:00:30 틈**은 `CLOSED` → `determine_routing`이 `ValueError`. 기존 하자이며 이번 변경과 무관하나 같이 손볼 후보.
|
||||
- **시간 가드가 두 계통으로 갈려 있다** — `limits.json` 기반은 `trailing_monitor` 단독이고, `surge_monitor`·`watchlist_monitor`·`behive_web`·`stock_analysis`는 각자 하드코딩. 제도 변경마다 파일별로 손봐야 한다. 통합하면 이후엔 JSON 편집 + plist 재생성 2단계로 줄어든다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 키움 API 확인 체크리스트 — **다음 작업의 선행 조건**
|
||||
|
||||
로컬 `~/.openclaw/docs/키움 REST API 문서.pdf`는 2026-05-07 검증본으로 **이번 제도 변경 이전판**이고, 키움 OpenAPI 포털에서 관련 공지를 찾지 못했다.
|
||||
⚠️ **CLAUDE.md 규칙("suffix 패턴 추측 금지 — 2026-05-07 ka10004 사고")에 따라 아래 값을 지어내지 않는다.**
|
||||
|
||||
1. **`dmst_stex_tp` 값** — 현재 매핑 `_EXCHANGE_BY_SUFFIX = {'_AL':'SOR', '_NX':'NXT', '':'KRX'}`(`kiwoom_order.py:36-41`). 애프터마켓 주문이 `KRX`로 그냥 나가는지, `SOR`가 자동 라우팅하는지, 신규 값이 생기는지.
|
||||
2. **`trde_tp` 값** — 현재 `'0'` 지정가 / `'3'` 시장가 / `'28'` 스톱지정가(`kiwoom_order.py:31-34`). 애프터마켓 전용 호가유형(최우선지정가·최유리지정가·IOC·FOK) 코드가 추가되는지.
|
||||
3. **애프터마켓 참여 가능 여부 플래그** — `ka10099 nxtEnable`은 NXT 상장 여부만 준다. **제외종목 9종류를 판별할 필드가 새로 생기는지**, 아니면 `order_warning`·`state` 텍스트로 근사해야 하는지.
|
||||
4. **애프터마켓 가격제한폭** — `ka10001`/`ka10095`의 `upl_pric`/`lst_pric`이 16:00 이후에도 정규장 기준값인지 애프터 전용 값인지. `surge_monitor` 상·하한가 트리거 정확도가 여기 걸려 있다.
|
||||
5. **VI 필드** — NXT 정적VI 도입으로 실시간 `1h` broadcast 페이로드가 확장되는지. `surge_monitor`가 `vi.active`를 그대로 신뢰한다(L373-377).
|
||||
6. **호가·시세 TR의 애프터마켓 구분** — `ka10004` 응답에 세션 구분 필드가 생기는지. 현재 자산웹은 시간외/애프터 구분 필드를 전혀 쓰지 않는다.
|
||||
7. **미체결 조회(`ka10075`) 동작** — KRX 주문이 15:35 증거금 해지 후 목록에서 즉시 빠지는지. `trailing_monitor` 생존 판정이 이 응답에 전적으로 의존한다.
|
||||
|
||||
**확인 방법**: 9/14(월) 장에서 ① 키움 REST API 포털·공지 재확인 ② 16:00 이후 `ka10095`·`ka10001` 응답 실측(조회 전용, 안전) ③ 필요시 NXT 미상장 보유주 1주로 애프터마켓 실주문 검증.
|
||||
|
||||
---
|
||||
|
||||
## 5. 확인 후 손댈 좌표 (미리 특정)
|
||||
|
||||
**§4 확인 전엔 손대지 않는다.**
|
||||
|
||||
| 대상 | 파일:라인 | 성격 |
|
||||
|---|---|---|
|
||||
| 세션 정의 | `scripts/orders/limits.json:37-49` | 애프터마켓 경계 추가 |
|
||||
| 세션 판정 | `scripts/orders/guards.py:78-89` (`session_at`) | 세션 1종 추가 |
|
||||
| 라우팅 | `scripts/orders/guards.py:143-161` (`determine_routing`) | 애프터 세션 라우팅 |
|
||||
| 시장가 게이트 | `scripts/orders/guards.py:195-205` | 애프터 세션 추가(거부 유지) |
|
||||
| 거래소 코드 | `scripts/orders/kiwoom_order.py:36-41` | §4-1 결과 반영 |
|
||||
| 자산웹 phase | `scripts/behive_web.py:788-808`, `:812`(`_PHASE_BOUNDARIES_MIN`) | phase 분리 + 16:00 경계 |
|
||||
| phase 라벨·게이트 | `scripts/behive_web.py:12845-12874`, `:12928-12935` | `📵 NXT 거래불가` 조건 재정의 |
|
||||
| 호가 거래소 선택 | `scripts/behive_web.py:14687-14717` | 애프터 세션 분기 |
|
||||
| 급등락 감시 | `scripts/surge_monitor.py:90-91`, `:306-320` + plist 396→약 660엔트리 | 시간 확대 + 제한폭 캐시 |
|
||||
| 워치리스트 감시 | `scripts/watchlist_monitor.py:47-48` + plist | 시간 확대 |
|
||||
| 트레일링 문구·전제 | `scripts/trailing_monitor.py:142`, `:487` | 소멸 시점 재실측 반영 |
|
||||
| 시세 조회 거래소 | `scripts/trailing_monitor.py:446` (`'NX' if sess in ('NXT_PRE','NXT_AFTER') else 'AL'`) | 세션명 추가 시 **동반 수정 필수** |
|
||||
| 경계 테스트 | `scripts/orders/tests/test_guards.py:45-72` | 12케이스 갱신 필수 |
|
||||
| 정책 문서 | `scripts/orders/__init__.py:38-42`, `memory/orders_module.md:38,41-42` | "08:00–20:00 NXT" 서술 정정 |
|
||||
|
||||
⚠️ **20:00 종료가 유지**되므로 `guards.after_trading_day`(= `nxt_after_end`)와 그 소비자(`orders/trailing.py:692` `is_arm_expired`), `trailing-monitor` plist(720엔트리), briefing 20:10/20:30·trade-journal 21:00 트리거는 **무변경 가능**.
|
||||
@@ -5,6 +5,7 @@
|
||||
- 한국어, 존댓말, 관리자님 호칭.
|
||||
- 4개 섹션 순서·제목 그대로 사용 (▣ 최근 가격 흐름 / ▣ 수급 해석 / ▣ 거래량·체결 강도 / ▣ 주의 포인트 / 리스크).
|
||||
- 각 섹션 5문장 이상, 수치 근거 인용.
|
||||
- 각 섹션에서 가장 중요한 핵심 수치·키워드 1~2곳만 `**...**`로 감싸 강조하세요(붉은 글씨로 표시됨). 남발 금지 — 한 문장에 하나 이하, 정말 중요한 곳만.
|
||||
- 비교업체(peers) 데이터는 종목의 기본정보(가격·PER/PBR/ROE 등 재무비율) 관련 맥락에서만 간단히 짚어주시고, 4개 분석 섹션(가격 흐름·수급·거래량·주의 포인트) 안에서는 비교 코멘트를 넣지 마세요.
|
||||
- 마지막에 한줄요약·투자의견 JSON을 정확한 형식으로 출력 (파서가 읽음).
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""@비하이브투자자문 신규 '종목분석' 영상 감지 + 자막 수집 + watchlist 저장 + 이메일/텔레그램 발송 + 조회.
|
||||
"""@비하이브투자자문 신규 '종목분석'/'관심테마' 영상 감지 + 자막 수집 + watchlist 저장 + 이메일/텔레그램 발송 + 조회.
|
||||
|
||||
Subcommands:
|
||||
fetch — 신규 매칭 영상 메타데이터 JSON 출력(자막 제외) + 자막은 fetch 캐시에만 저장
|
||||
@@ -42,7 +42,7 @@ CHANNEL_ID = 'UCHTRF5r154igU2gXjudUMzg'
|
||||
CHANNEL_NAME = '비하이브 투자자문'
|
||||
FEED_URL = f'https://www.youtube.com/feeds/videos.xml?channel_id={CHANNEL_ID}'
|
||||
SEARCH_URL = 'https://www.youtube.com/results?search_query={query}&sp=CAISAhAB'
|
||||
TITLE_FILTER = '종목분석'
|
||||
TITLE_FILTERS = ('종목분석', '관심테마')
|
||||
TRANSCRIPT_LANGS = ['ko', 'ko-KR', 'en']
|
||||
TRANSCRIPT_CHAR_LIMIT = 8000
|
||||
FETCH_LIMIT = 10
|
||||
@@ -139,8 +139,19 @@ def _is_recent_published_text(text: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _matches_title_filter(title: str) -> bool:
|
||||
return any(keyword in title for keyword in TITLE_FILTERS)
|
||||
|
||||
|
||||
def _video_kind(title: str) -> str:
|
||||
for keyword in TITLE_FILTERS:
|
||||
if keyword in title:
|
||||
return keyword
|
||||
return '비하이브'
|
||||
|
||||
|
||||
def _search_results_fallback() -> list[dict]:
|
||||
query = urllib.parse.quote(f'{CHANNEL_NAME} {TITLE_FILTER}')
|
||||
query = urllib.parse.quote(f'{CHANNEL_NAME} {" OR ".join(TITLE_FILTERS)}')
|
||||
url = SEARCH_URL.format(query=query)
|
||||
req = urllib.request.Request(url, headers={'User-Agent': FEED_USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
@@ -178,7 +189,7 @@ def _search_results_fallback() -> list[dict]:
|
||||
title = ''.join(run.get('text', '') for run in vr.get('title', {}).get('runs', []))
|
||||
vid = vr.get('videoId', '')
|
||||
published = vr.get('publishedTimeText', {}).get('simpleText', '')
|
||||
if owner != CHANNEL_NAME or TITLE_FILTER not in title or not vid or vid in seen_ids:
|
||||
if owner != CHANNEL_NAME or not _matches_title_filter(title) or not vid or vid in seen_ids:
|
||||
continue
|
||||
if not _is_recent_published_text(published):
|
||||
continue
|
||||
@@ -186,6 +197,7 @@ def _search_results_fallback() -> list[dict]:
|
||||
entries.append({
|
||||
'video_id': vid,
|
||||
'title': title.strip(),
|
||||
'kind': _video_kind(title),
|
||||
'published': published,
|
||||
'url': f'https://www.youtube.com/watch?v={vid}',
|
||||
})
|
||||
@@ -231,6 +243,7 @@ def fetch_feed() -> list[dict]:
|
||||
entries.append({
|
||||
'video_id': vid,
|
||||
'title': title.strip(),
|
||||
'kind': _video_kind(title),
|
||||
'published': published,
|
||||
'url': link or f'https://www.youtube.com/watch?v={vid}',
|
||||
})
|
||||
@@ -267,7 +280,7 @@ def cmd_fetch() -> int:
|
||||
entries = fetch_feed()
|
||||
matched = [
|
||||
e for e in entries
|
||||
if TITLE_FILTER in e['title'] and e['video_id'] not in seen
|
||||
if _matches_title_filter(e['title']) and e['video_id'] not in seen
|
||||
]
|
||||
matched = list(reversed(matched))[:FETCH_LIMIT]
|
||||
for item in matched:
|
||||
@@ -304,6 +317,7 @@ def load_cached_video(video_id: str) -> dict:
|
||||
return {
|
||||
'id': item.get('video_id'),
|
||||
'title': item.get('title'),
|
||||
'kind': item.get('kind') or _video_kind(item.get('title') or ''),
|
||||
'url': item.get('url'),
|
||||
'published': item.get('published'),
|
||||
}
|
||||
@@ -435,7 +449,8 @@ def _buy_primary(entry: dict) -> float | None:
|
||||
|
||||
def format_entry_block(entry: dict) -> str:
|
||||
stock = entry.get('stock', '')
|
||||
lines = [f'[종목분석] #{stock}', '━━━━━━━━━━']
|
||||
kind = (entry.get('video') or {}).get('kind') or '종목분석'
|
||||
lines = [f'[{kind}] #{stock}', '━━━━━━━━━━']
|
||||
target_line = format_price_line('목표가', entry.get('target'))
|
||||
upside = entry.get('upside_pct')
|
||||
if upside is not None and '언급 없음' not in target_line:
|
||||
@@ -480,10 +495,11 @@ def _format_current_html(current: str | None) -> str:
|
||||
def format_entry_html_block(entry: dict) -> str:
|
||||
S = HTML_STYLES
|
||||
stock = entry.get('stock', '')
|
||||
kind = (entry.get('video') or {}).get('kind') or '종목분석'
|
||||
current = fetch_current_price(entry.get('code') or stock, _buy_primary(entry))
|
||||
parts = [f'<div style="{S["card"]}">']
|
||||
parts.append(
|
||||
f'<div style="{S["card_title"]}">[종목분석] #{html_escape(stock)} '
|
||||
f'<div style="{S["card_title"]}">[{html_escape(kind)}] #{html_escape(stock)} '
|
||||
f'<span style="{S["card_meta"]}">현재가: {_format_current_html(current)}</span></div>'
|
||||
)
|
||||
|
||||
@@ -541,7 +557,7 @@ def build_email_html(entries: list[dict]) -> str:
|
||||
S = HTML_STYLES
|
||||
parts = [
|
||||
f'<div style="{S["wrap"]}">',
|
||||
f'<div style="{S["greet"]}">관리자님, 비하이브투자자문 신규 종목분석 {len(entries)}건을 전달드립니다.</div>',
|
||||
f'<div style="{S["greet"]}">관리자님, 비하이브투자자문 신규 추천 영상 {len(entries)}건을 전달드립니다.</div>',
|
||||
]
|
||||
for entry in entries:
|
||||
parts.append(format_entry_html_block(entry))
|
||||
@@ -585,7 +601,7 @@ def cmd_email(video_ids: list[str]) -> int:
|
||||
return 1
|
||||
date_str = datetime.now(KST).strftime('%Y-%m-%d %H:%M')
|
||||
stock_names = ', '.join(e.get('stock', '') for e in entries)
|
||||
subject = f'[비하이브 종목분석] {len(entries)}개 보고서 — {date_str} ({stock_names})'
|
||||
subject = f'[비하이브 추천영상] {len(entries)}개 보고서 — {date_str} ({stock_names})'
|
||||
html_body = build_email_html(entries)
|
||||
cmd = ['gog', 'gmail', 'send', '--to', EMAIL_RECIPIENT, '--subject', subject, '--body-html', html_body]
|
||||
p = subprocess.run(cmd, text=True, capture_output=True)
|
||||
@@ -652,7 +668,7 @@ def cmd_notify(video_ids: list[str]) -> int:
|
||||
if not entries:
|
||||
print('no matching entries in watchlist', file=sys.stderr)
|
||||
return 1
|
||||
header = f'비하이브 종목분석 {len(entries)}건 제출'
|
||||
header = f'비하이브 추천영상 {len(entries)}건 제출'
|
||||
blocks = [format_telegram_block(e) for e in entries]
|
||||
text = header + '\n\n' + '\n\n'.join(blocks)
|
||||
if send_telegram(text):
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
"""stock.briefing 폴백·강제 재실행.
|
||||
|
||||
메일은 발송하지 않는다 (2026-08-12 관리자님 결정 — 주식 리포트 메일 중지, 스냅샷만 유지).
|
||||
목적은 자산웹 '당일 평가손익' 기준값이 되는 오늘 스냅샷을 확보하는 것.
|
||||
|
||||
retry/final: 오늘 portfolio_daily_snapshot.json에 오늘 키가 이미 있으면 skip (idempotent).
|
||||
없으면 stock_portfolio_report.py send 재실행. final 모드에선 그래도 실패 시 레이 텔레그램 알림.
|
||||
없으면 stock_portfolio_report.py send --no-mail 재실행. final 모드에선 그래도 실패 시 레이 텔레그램 알림.
|
||||
|
||||
force: 스냅샷 존재 여부 무관하게 21:00 fresh fetch로 데이터 덮어쓰기. 20:10 데이터가
|
||||
부정확할 때 21:00 안정된 데이터로 갱신용. 스냅샷 있으면 run 모드(스냅샷만, 메일·텔레그램 X),
|
||||
없으면 send 모드로 폴백 + 실패 시 알림.
|
||||
부정확할 때 21:00 안정된 데이터로 갱신용. 스냅샷 있으면 run 모드, 없으면 send --no-mail 폴백
|
||||
+ 실패 시 알림.
|
||||
|
||||
usage:
|
||||
briefing_fallback.py retry # 20:30 — 스냅샷 없으면 재실행, 실패해도 알림 없음
|
||||
@@ -42,7 +45,9 @@ def today_snapshot_exists() -> bool:
|
||||
|
||||
|
||||
def run_briefing() -> int:
|
||||
return subprocess.call(['/usr/bin/python3', str(REPORT), 'send'])
|
||||
# 2026-08-12부터 주식 리포트 메일은 발송하지 않는다 (스냅샷만). --no-mail 은 send 경로의
|
||||
# 휴장일 가드·실패 알림을 유지하면서 메일만 건너뛴다.
|
||||
return subprocess.call(['/usr/bin/python3', str(REPORT), 'send', '--no-mail'])
|
||||
|
||||
|
||||
def alert(text: str) -> None:
|
||||
@@ -69,13 +74,13 @@ def main() -> int:
|
||||
|
||||
if mode == 'force':
|
||||
# 21:00 무조건 재실행 — 20:10 데이터가 부정확할 때 fresh fetch로 덮어씀.
|
||||
# 스냅샷 있으면 run(스냅샷만), 없으면 send(메일·텔레그램·스냅샷 + 실패 시 알림).
|
||||
# 스냅샷 있으면 run(스냅샷만), 없으면 send --no-mail(스냅샷 + 실패 시 알림).
|
||||
if today_snapshot_exists():
|
||||
print(f'[force] {now} 스냅샷 갱신 (run 모드, 메일·텔레그램 없음).')
|
||||
code = subprocess.call(['/usr/bin/python3', str(REPORT), 'run'])
|
||||
print(f'[force] {now} run exit={code}')
|
||||
return 0 if code == 0 else 1
|
||||
print(f'[force] {now} 스냅샷 없음 → send 재실행 (메일·텔레그램 포함).')
|
||||
print(f'[force] {now} 스냅샷 없음 → send --no-mail 재실행 (메일 없음).')
|
||||
code = run_briefing()
|
||||
print(f'[force] {now} send exit={code}')
|
||||
if today_snapshot_exists():
|
||||
@@ -83,7 +88,7 @@ def main() -> int:
|
||||
alert(
|
||||
f'⚠️ stock.briefing {now} 폴백 모두 실패\n'
|
||||
f'오늘({datetime.now(KST):%Y-%m-%d}) 스냅샷이 생성되지 않았습니다.\n'
|
||||
f'수동 실행: python3 {REPORT} send'
|
||||
f'수동 실행: python3 {REPORT} send --no-mail'
|
||||
)
|
||||
return 1
|
||||
|
||||
@@ -103,7 +108,7 @@ def main() -> int:
|
||||
alert(
|
||||
f'⚠️ stock.briefing {now} 폴백 모두 실패\n'
|
||||
f'오늘({datetime.now(KST):%Y-%m-%d}) 스냅샷이 생성되지 않았습니다.\n'
|
||||
f'수동 실행: python3 {REPORT} send'
|
||||
f'수동 실행: python3 {REPORT} send --no-mail'
|
||||
)
|
||||
return 1 if mode == 'final' else 0
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""미 연준 FOMC 회의 일정 → Google 캘린더 동기화 (조회+등록 전용).
|
||||
|
||||
출처: federalreserve.gov 공식 FOMC 일정표. 회의 날짜는 미 동부 기준 그대로 등록한다
|
||||
(기존 수동 등록분·시황 자막 관례와 동일).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
ET = ZoneInfo('America/New_York')
|
||||
CALENDAR_ID = 'mini.snowoyh@gmail.com'
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
STATE_DIR = WORKSPACE / 'state'
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE = STATE_DIR / 'fomc_calendar_sync.json'
|
||||
SOURCE_URL = 'https://www.federalreserve.gov/monetarypolicy/fomccalendars.htm'
|
||||
|
||||
MONTHS = {
|
||||
'january': 1, 'february': 2, 'march': 3, 'april': 4, 'may': 5, 'june': 6,
|
||||
'july': 7, 'august': 8, 'september': 9, 'october': 10, 'november': 11, 'december': 12,
|
||||
}
|
||||
|
||||
YEAR_PANEL_RE = re.compile(r'(\d{4})\s+FOMC\s+Meetings')
|
||||
MEETING_ROW_RE = re.compile(
|
||||
r'fomc-meeting__month[^>]*>\s*(?:<strong>)?(.*?)(?:</strong>)?\s*</div>'
|
||||
r'.*?fomc-meeting__date[^>]*>\s*(.*?)\s*</div>',
|
||||
re.S,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Meeting:
|
||||
start_date: date # 회의 시작 (미 동부)
|
||||
end_date: date # 회의 종료 (미 동부, inclusive)
|
||||
is_sep: bool
|
||||
|
||||
@property
|
||||
def state_key(self) -> str:
|
||||
# FOMC는 한 달에 두 번 열리지 않으므로 시작월이 고유 키
|
||||
return f'{self.start_date:%Y-%m}'
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
base = f'[FOMC] {self.state_key} 회의'
|
||||
return f'{base} (SEP)' if self.is_sep else base
|
||||
|
||||
@property
|
||||
def announce_kst(self) -> datetime:
|
||||
"""성명 발표 = 회의 마지막날 미 동부 14:00. 서머타임은 zoneinfo가 처리."""
|
||||
et = datetime(self.end_date.year, self.end_date.month, self.end_date.day, 14, 0, tzinfo=ET)
|
||||
return et.astimezone(KST)
|
||||
|
||||
@property
|
||||
def cal_start(self) -> date:
|
||||
return self.start_date
|
||||
|
||||
@property
|
||||
def cal_end(self) -> date:
|
||||
"""캘린더 이벤트 마지막날 = 발표일(KST). 미 동부 회의 종료 다음날이라
|
||||
한국 장이 결과를 반영하는 날까지 일정이 덮인다."""
|
||||
return self.announce_kst.date()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
if self.start_date == self.end_date:
|
||||
days = self.start_date.isoformat()
|
||||
else:
|
||||
days = f'{self.start_date.isoformat()} ~ {self.end_date.isoformat()}'
|
||||
lines = [
|
||||
f'회의일(미 동부): {days}',
|
||||
f'성명 발표: {self.announce_kst:%Y-%m-%d %H:%M} KST (한국 장 반영일)',
|
||||
]
|
||||
if self.is_sep:
|
||||
lines.append('경제전망(SEP)·점도표 공개 회의')
|
||||
lines.append(f'출처: {SOURCE_URL}')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> str:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(p.stderr.strip() or p.stdout.strip() or 'command failed')
|
||||
return p.stdout
|
||||
|
||||
|
||||
def fetch(url: str) -> str:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
return r.read().decode('utf-8', 'ignore')
|
||||
|
||||
|
||||
def parse_meetings(html: str, after: date) -> list[Meeting]:
|
||||
"""연도 패널별로 회의 행을 훑어 캘린더 기간이 아직 안 끝난 회의만 반환.
|
||||
|
||||
기준을 회의 시작일이 아니라 `cal_end`(발표일 KST)로 두는 이유: 미 동부 회의가
|
||||
끝났어도 한국 장 반영일이 남아 있으면 아직 관리 대상이다.
|
||||
"""
|
||||
panels = [(m.group(1), m.start()) for m in YEAR_PANEL_RE.finditer(html)]
|
||||
if not panels:
|
||||
return []
|
||||
bounds = panels + [('', len(html))]
|
||||
|
||||
meetings: list[Meeting] = []
|
||||
for (year_raw, start), (_, end) in zip(bounds[:-1], bounds[1:]):
|
||||
year = int(year_raw)
|
||||
if year < after.year:
|
||||
continue
|
||||
for row in MEETING_ROW_RE.finditer(html[start:end]):
|
||||
month_txt = re.sub(r'<[^>]+>', '', row.group(1)).strip()
|
||||
day_txt = re.sub(r'<[^>]+>', '', row.group(2)).strip()
|
||||
m = parse_row(year, month_txt, day_txt)
|
||||
if m and m.cal_end > after:
|
||||
meetings.append(m)
|
||||
meetings.sort(key=lambda m: m.start_date)
|
||||
return meetings
|
||||
|
||||
|
||||
def parse_row(year: int, month_txt: str, day_txt: str) -> Meeting | None:
|
||||
"""'April/May' + '28-1*' 같은 월 경계 표기까지 처리."""
|
||||
months = [MONTHS[p.strip().lower()] for p in month_txt.split('/') if p.strip().lower() in MONTHS]
|
||||
if not months:
|
||||
return None
|
||||
|
||||
is_sep = '*' in day_txt
|
||||
days = [int(d) for d in re.findall(r'\d+', day_txt)]
|
||||
if not days:
|
||||
return None
|
||||
|
||||
start_month = months[0]
|
||||
end_month = months[-1]
|
||||
start_year = year
|
||||
end_year = year if end_month >= start_month else year + 1 # 12월/1월 회의 대비
|
||||
|
||||
try:
|
||||
start = date(start_year, start_month, days[0])
|
||||
end = date(end_year, end_month, days[-1])
|
||||
except ValueError:
|
||||
return None
|
||||
if end < start:
|
||||
return None
|
||||
return Meeting(start, end, is_sep)
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def save_state(state: dict):
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def state_key_from_summary(summary: str) -> str | None:
|
||||
m = re.match(r'\[FOMC\]\s*(\d{4}-\d{2})\b', (summary or '').strip())
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def fetch_existing_events(start_date: date, end_date: date) -> dict[str, list[dict]]:
|
||||
start_dt = datetime(start_date.year, start_date.month, start_date.day, 0, 0, tzinfo=KST)
|
||||
end_dt = datetime(end_date.year, end_date.month, end_date.day, 0, 0, tzinfo=KST)
|
||||
out = run([
|
||||
'gog', 'calendar', 'events', CALENDAR_ID,
|
||||
'--from', start_dt.isoformat(), '--to', end_dt.isoformat(),
|
||||
'--all-pages', '--max', '250', '--json'
|
||||
])
|
||||
data = json.loads(out)
|
||||
existing: dict[str, list[dict]] = {}
|
||||
for ev in data.get('events', []):
|
||||
key = state_key_from_summary(ev.get('summary', ''))
|
||||
if key:
|
||||
existing.setdefault(key, []).append(ev)
|
||||
return existing
|
||||
|
||||
|
||||
def create_event(m: Meeting, dry_run: bool = False):
|
||||
start = m.cal_start.isoformat()
|
||||
end = (m.cal_end + timedelta(days=1)).isoformat() # 종일 이벤트의 end는 exclusive
|
||||
if dry_run:
|
||||
print(json.dumps({'create': m.summary, 'from': start, 'to': m.cal_end.isoformat()}, ensure_ascii=False))
|
||||
return
|
||||
# --event-color 미지정: 기존 이벤트에 지정된 색을 덮어쓰지 않는다
|
||||
run([
|
||||
'gog', 'calendar', 'create', CALENDAR_ID,
|
||||
'--summary', m.summary,
|
||||
'--description', m.description,
|
||||
'--from', start,
|
||||
'--to', end,
|
||||
'--all-day',
|
||||
])
|
||||
|
||||
|
||||
def update_event(event_id: str, m: Meeting, dry_run: bool = False) -> str:
|
||||
start = m.cal_start.isoformat()
|
||||
end = (m.cal_end + timedelta(days=1)).isoformat()
|
||||
if dry_run:
|
||||
print(json.dumps({'update': m.summary, 'event_id': event_id, 'from': start, 'to': m.cal_end.isoformat()}, ensure_ascii=False))
|
||||
return 'updated'
|
||||
try:
|
||||
run([
|
||||
'gog', 'calendar', 'update', CALENDAR_ID, event_id,
|
||||
'--summary', m.summary,
|
||||
'--description', m.description,
|
||||
'--from', start,
|
||||
'--to', end,
|
||||
'--all-day',
|
||||
])
|
||||
return 'updated'
|
||||
except Exception:
|
||||
run(['gog', 'calendar', 'delete', CALENDAR_ID, event_id, '--force', '--no-input'])
|
||||
create_event(m, dry_run=False)
|
||||
return 'recreated'
|
||||
|
||||
|
||||
def delete_event(event_id: str, dry_run: bool = False):
|
||||
if dry_run:
|
||||
print(json.dumps({'delete_event_id': event_id}, ensure_ascii=False))
|
||||
return
|
||||
run(['gog', 'calendar', 'delete', CALENDAR_ID, event_id, '--force', '--no-input'])
|
||||
|
||||
|
||||
def event_date_range(ev: dict) -> tuple[str, str]:
|
||||
start_info = ev.get('start', {})
|
||||
end_info = ev.get('end', {})
|
||||
start = start_info.get('date') or start_info.get('dateTime', '')[:10]
|
||||
end_excl = end_info.get('date') or end_info.get('dateTime', '')[:10]
|
||||
end = ''
|
||||
if end_excl:
|
||||
end = (date.fromisoformat(end_excl) - timedelta(days=1)).isoformat()
|
||||
return start, end
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
parser.add_argument('--list', action='store_true', help='파싱 결과만 출력 (캘린더 미접근)')
|
||||
args = parser.parse_args()
|
||||
|
||||
today = datetime.now(KST).date()
|
||||
meetings = parse_meetings(fetch(SOURCE_URL), today)
|
||||
|
||||
if args.list:
|
||||
print(json.dumps({
|
||||
'today': today.isoformat(),
|
||||
'total': len(meetings),
|
||||
'meetings': [
|
||||
{'key': m.state_key, 'summary': m.summary,
|
||||
'meeting_et': f'{m.start_date.isoformat()}~{m.end_date.isoformat()}',
|
||||
'announce_kst': m.announce_kst.strftime('%Y-%m-%d %H:%M'),
|
||||
'cal_start': m.cal_start.isoformat(), 'cal_end': m.cal_end.isoformat()}
|
||||
for m in meetings
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return
|
||||
|
||||
state = load_state()
|
||||
end_date = max((m.cal_end for m in meetings), default=today) + timedelta(days=1)
|
||||
# 스크래핑 실패 시 기존 일정이 몰살되는 사고 방지 — meetings 비면 cleanup 스킵
|
||||
# 조회 시작이 today인 건 오늘 걸쳐 있는 진행 중 회의도 갱신 대상이기 때문
|
||||
existing = {} if not meetings else fetch_existing_events(today, end_date)
|
||||
|
||||
created, updated, recreated, deleted, duplicates_removed = [], [], [], [], []
|
||||
unchanged = 0
|
||||
changes = []
|
||||
keys = {m.state_key for m in meetings}
|
||||
|
||||
for m in meetings:
|
||||
existing_list = existing.get(m.state_key, [])
|
||||
# 같은 키로 여러 건이면 가장 최근 created를 남기고 나머지 삭제
|
||||
if len(existing_list) > 1:
|
||||
existing_list.sort(key=lambda e: e.get('created', ''), reverse=True)
|
||||
for dup in existing_list[1:]:
|
||||
delete_event(dup['id'], dry_run=args.dry_run)
|
||||
duplicates_removed.append(m.state_key)
|
||||
changes.append({'key': m.state_key, 'action': 'duplicate_removed', 'event_id': dup['id']})
|
||||
|
||||
existing_ev = existing_list[0] if existing_list else None
|
||||
|
||||
if not existing_ev:
|
||||
create_event(m, dry_run=args.dry_run)
|
||||
created.append(m.state_key)
|
||||
changes.append({'key': m.state_key, 'action': 'created',
|
||||
'new_date': f'{m.cal_start.isoformat()}~{m.cal_end.isoformat()}'})
|
||||
continue
|
||||
|
||||
existing_start, existing_end = event_date_range(existing_ev)
|
||||
desired_start, desired_end = m.cal_start.isoformat(), m.cal_end.isoformat()
|
||||
same_summary = (existing_ev.get('summary') or '').strip() == m.summary
|
||||
same_desc = (existing_ev.get('description') or '').strip() == m.description.strip()
|
||||
|
||||
if existing_start != desired_start or existing_end != desired_end or not same_summary or not same_desc:
|
||||
result = update_event(existing_ev['id'], m, dry_run=args.dry_run)
|
||||
(recreated if result == 'recreated' else updated).append(m.state_key)
|
||||
changes.append({'key': m.state_key, 'action': result,
|
||||
'old_date': f'{existing_start}~{existing_end}',
|
||||
'new_date': f'{desired_start}~{desired_end}'})
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
# 연준 일정표에서 사라진 미래 회의는 캘린더에서도 삭제 (meetings 비면 existing이 빈 dict라 자동 스킵)
|
||||
for key, existing_list in existing.items():
|
||||
if key in keys:
|
||||
continue
|
||||
for stale in existing_list:
|
||||
old_start, old_end = event_date_range(stale)
|
||||
# 오늘 진행 중이거나 지난 회의는 parse 대상(오늘 이후)에서 빠져 있을 뿐이므로 삭제 금지
|
||||
if old_start and old_start <= today.isoformat():
|
||||
continue
|
||||
delete_event(stale['id'], dry_run=args.dry_run)
|
||||
deleted.append(key)
|
||||
changes.append({'key': key, 'action': 'deleted', 'event_id': stale['id'],
|
||||
'old_date': f'{old_start}~{old_end}'})
|
||||
|
||||
state['last_changes'] = changes
|
||||
state['last_run_at'] = datetime.now(KST).isoformat()
|
||||
if not args.dry_run:
|
||||
save_state(state)
|
||||
|
||||
print(json.dumps({
|
||||
'today': today.isoformat(), 'total_found': len(meetings),
|
||||
'newly_created': len(created), 'updated': len(updated), 'recreated': len(recreated),
|
||||
'deleted': len(deleted), 'duplicates_removed': len(duplicates_removed),
|
||||
'unchanged': unchanged, 'dry_run': args.dry_run, 'changes': changes,
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -222,20 +222,34 @@ def cmd_sync(years_forward: int) -> int:
|
||||
# 양쪽 다 0건이면 영구 변경/네트워크 차단 의심 — health 카운트 + 2회 연속 시 알림.
|
||||
both_failed = (not investing_data) and (not nager_data_raw)
|
||||
_update_health(both_failed, errors)
|
||||
if not investing_data:
|
||||
sys.stderr.write('no holidays parsed — investing.com 미반영. state file은 옛 데이터 유지.\n')
|
||||
if not investing_data and not nager_data_raw:
|
||||
sys.stderr.write('no holidays parsed — 두 소스 모두 실패. state file은 옛 데이터 유지.\n')
|
||||
return 3
|
||||
# KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표 → 그 전엔 investing.com도 비어 있음.
|
||||
# investing이 한 건이라도 잡은 연도에만 nager 보강 — 미발표 연도의 일반 공휴일이 KRX 휴장으로 잘못 등록되는 것 차단.
|
||||
investing_years = {int(d[:4]) for d in investing_data}
|
||||
nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in investing_years}
|
||||
# 채울 연도 판정. KRX는 다음 연도 휴장 캘린더를 보통 11~12월에 공식 발표하므로,
|
||||
# 미발표 연도의 일반 공휴일을 KRX 휴장으로 잘못 등록하지 않도록 게이트를 둔다.
|
||||
# - investing(거래소 캘린더)이 살아 있으면 그게 곧 발표 신호 → 그 연도만 채운다(원래 규칙).
|
||||
# - investing이 죽었으면(2026-09 이후 403 차단) 달력으로 대신 판정 — 올해는 항상,
|
||||
# 내년은 11월 이후에만. 이래야 12월→1월 갭도 막고 미발표 연도도 오염되지 않는다.
|
||||
if investing_data:
|
||||
allowed_years = {int(d[:4]) for d in investing_data}
|
||||
else:
|
||||
now = datetime.now(KST)
|
||||
allowed_years = {now.year} | ({now.year + 1} if now.month >= 11 else set())
|
||||
nager_data = {d: n for d, n in nager_data_raw.items() if int(d[:4]) in allowed_years}
|
||||
# investing.com(거래소 캘린더) 우선 — KRX 휴장 권위. nager는 누락분만 채움.
|
||||
holidays = {**nager_data, **investing_data}
|
||||
nager_extra = sorted(d for d in nager_data if d not in investing_data)
|
||||
# nager는 '공휴일'이라 거래소 전용 휴장인 연말 폐장일(12/31)이 없다. 평일일 때만 보강.
|
||||
for y in sorted(allowed_years):
|
||||
eoy = date(y, 12, 31)
|
||||
key = eoy.isoformat()
|
||||
if eoy.weekday() < 5 and key not in holidays:
|
||||
holidays[key] = '연말 휴장'
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
'fetched_at': datetime.now(KST).isoformat(),
|
||||
'sources': {'primary': URL, 'fallback': NAGER_URL.format(year='YYYY')},
|
||||
'source_used': 'investing+nager' if investing_data else 'nager-only',
|
||||
'years': years,
|
||||
'holidays': dict(sorted(holidays.items())),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
#!/usr/bin/env python3
|
||||
"""투자원금(순입금) 집계 — 조회 전용.
|
||||
|
||||
투자원금 = 계좌 개설 이후 누적 입금 − 누적 출금. "내 돈 얼마 넣었나"이며,
|
||||
`총 매입금액`(현재 보유종목 매입원가)이나 `예수금`(남은 현금)과 전혀 다른 값이다.
|
||||
이 값이 있어야 `순자산 − 투자원금 = 진짜 총수익`(실현+미실현+배당)이 나온다.
|
||||
키움 kt00018의 평가손익은 현재 보유분 미실현만 잡아 누적 실현손익·배당을 놓친다.
|
||||
|
||||
데이터원은 kt00016(일별계좌수익률상세현황)의 기간내총입금·총출금.
|
||||
⚠️ kt00016은 **조회기간 1년 제한**이라 연도별 창으로 쪼개 누적한다.
|
||||
|
||||
캐시 `state/investment_principal.json`:
|
||||
- frozen : 지난 연도 = 확정값. 한 번 적재 후 재조회 안 함 (0인 연도는 저장 생략)
|
||||
- current: 올해 창(0101~오늘) 누적. 갱신 대상은 이것뿐 → 계좌당 1콜
|
||||
- seen : 마지막 갱신 시점의 "당일 입출금" 스냅샷 — 이벤트 감지 기준
|
||||
|
||||
갱신은 별도 트리거 없이 behive_web 렌더 경로에서 이벤트로만 일어난다.
|
||||
당일 입출금(이미 kt00015로 조회 중)이 seen과 다를 때만 올해 창을 재조회하므로
|
||||
입출금 없는 평시에는 kt00016 호출이 0이다. 안전망으로 7일 초과 시 강제 갱신.
|
||||
|
||||
CLI: python3 scripts/investment_principal.py {bootstrap|refresh|show}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import kiwoom_client as kc
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
STATE_FILE = WORKSPACE / 'state' / 'investment_principal.json'
|
||||
|
||||
# 부트스트랩 하한 연도. 계좌 개설 전 기간은 kt00016이 에러 없이 전 필드 0을 주므로
|
||||
# 넉넉히 잡아도 안전하다 (현재 최고(最古) 계좌는 2018년 개설).
|
||||
FLOOR_YEAR = 2010
|
||||
PACE_SEC = 0.35 # 연속 조회 rate limit 페이싱
|
||||
STALE_DAYS = 7 # 이벤트 감지가 못 잡은 경우의 안전망
|
||||
|
||||
# 적요에 이 중 하나가 들어가면 '내가 넣은/뺀 돈'(원금 반영), 아니면 투자 성과(원금 무관).
|
||||
# 원금 반영 : 이체입금(지급결제)·이체오픈뱅킹입금·이체입금·대체입금·소액이체인증입금
|
||||
# ·ISA가입인증입금 / 이체출금·대체출금
|
||||
# 원금 무관 : 배당금입금·수익분배금입금·예탁금이용료(이자)입금·공모주환불금입금
|
||||
# ·쿠폰현금지급(국내)입금·단주매각대금입금·대여수수료입금
|
||||
# / 공모불입출금·청약수수료출금
|
||||
# ⚠️ 추측이 아니라 실증된 규칙 — 이 분류의 합이 kt00016 기간내총입금/총출금과
|
||||
# 4계좌 18개 (계좌,연도) 구간 전부에서 오차 0으로 일치했다(2026-07-29).
|
||||
# 새 적요가 등장하면 reconciled=False로 드러나므로 조용히 틀리지 않는다.
|
||||
_PRINCIPAL_RMRK_KEYS = ('이체', '대체', '인증')
|
||||
|
||||
|
||||
def is_principal_flow(rmrk: str) -> bool:
|
||||
"""적요가 투자원금에 반영되는 외부 입출금인지. 배당·이자 등은 False."""
|
||||
return any(k in (rmrk or '') for k in _PRINCIPAL_RMRK_KEYS)
|
||||
|
||||
|
||||
# ---- 캐시 I/O ----
|
||||
|
||||
@contextmanager
|
||||
def _lock():
|
||||
"""STATE_FILE 직렬화 — behive_web _stock_notes_lock 과 동일 패턴, 별도 lock 파일."""
|
||||
import fcntl
|
||||
lock_path = STATE_FILE.with_suffix(STATE_FILE.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
def _load() -> dict | None:
|
||||
"""캐시 로드. 파일 없거나 깨졌으면 None (호출측이 '미집계'로 처리)."""
|
||||
if not STATE_FILE.exists():
|
||||
return None
|
||||
try:
|
||||
d = json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(d, dict) or 'current' not in d:
|
||||
return None
|
||||
d.setdefault('frozen', {})
|
||||
d.setdefault('seen', {})
|
||||
d['current'].setdefault('labels', {})
|
||||
return d
|
||||
|
||||
|
||||
def _save(d: dict) -> None:
|
||||
d['refreshed_at'] = datetime.now(KST).isoformat(timespec='seconds')
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(d, ensure_ascii=False, indent=2))
|
||||
os.replace(tmp, STATE_FILE)
|
||||
|
||||
|
||||
# ---- 조회 ----
|
||||
|
||||
def _today_str() -> str:
|
||||
return datetime.now(KST).strftime('%Y%m%d')
|
||||
|
||||
|
||||
def _this_year() -> int:
|
||||
return datetime.now(KST).year
|
||||
|
||||
|
||||
def _window(year: int) -> tuple[str, str]:
|
||||
"""연도별 조회 창. 올해는 오늘까지, 지난 연도는 12/31까지 (1년 제한 내)."""
|
||||
if year == _this_year():
|
||||
return f'{year}0101', _today_str()
|
||||
return f'{year}0101', f'{year}1231'
|
||||
|
||||
|
||||
def _fetch_year(label: str, year: int) -> dict:
|
||||
"""한 (계좌, 연도) 창의 합계(kt00016) + 입출금 행(kt00015). 2콜.
|
||||
|
||||
flows 행의 `pr`은 원금 반영 여부. 분류 합이 kt00016 합계와 맞는지 `rec`에 남겨
|
||||
새 적요가 등장해 분류가 어긋나면 화면에서 드러나게 한다.
|
||||
"""
|
||||
fr, to = _window(year)
|
||||
r = kc.get_period_return(label, fr, to)
|
||||
time.sleep(PACE_SEC)
|
||||
try:
|
||||
raw = kc.get_cash_flow(label, strt_dt=fr, end_dt=to)
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'cash flow {label} {year} 실패: {e}\n')
|
||||
raw = None
|
||||
flows = None if raw is None else [
|
||||
{'d': f['date'], 'io': f['io_tp'], 'amt': f['amount'],
|
||||
'rk': f['rmrk'], 'pr': is_principal_flow(f['rmrk'])}
|
||||
for f in raw
|
||||
]
|
||||
rec = None
|
||||
if flows is not None:
|
||||
pin = sum(f['amt'] for f in flows if f['io'] == 'IN' and f['pr'])
|
||||
pout = sum(f['amt'] for f in flows if f['io'] == 'OUT' and f['pr'])
|
||||
rec = (pin == r['cash_in'] and pout == r['cash_out'])
|
||||
return {
|
||||
'in': r['cash_in'], 'out': r['cash_out'],
|
||||
'qin': r['qty_in'], 'qout': r['qty_out'],
|
||||
'nf': r['net_fr'], 'nt': r['net_to'], 'pl': r['eval_pl'],
|
||||
'flows': flows, 'rec': rec,
|
||||
}
|
||||
|
||||
|
||||
def _is_empty(y: dict) -> bool:
|
||||
"""계좌 개설 전(또는 전면 무활동) 연도 — 저장 생략 대상."""
|
||||
return not any(y[k] for k in ('in', 'out', 'qin', 'qout', 'nf', 'nt', 'pl'))
|
||||
|
||||
|
||||
def _labels(labels: list[str] | None = None) -> list[str]:
|
||||
all_lb = [a['label'] for a in kc.list_accounts()]
|
||||
if labels is None:
|
||||
return all_lb
|
||||
wanted = set(labels)
|
||||
return [lb for lb in all_lb if lb in wanted]
|
||||
|
||||
|
||||
def _flow_totals(cash_flow_by_label: dict) -> dict:
|
||||
"""behive_web의 kt00015 당일 입출금 → {label: {'in': n, 'out': n}}."""
|
||||
out: dict[str, dict] = {}
|
||||
for lb, flows in (cash_flow_by_label or {}).items():
|
||||
ci = sum(f['amount'] for f in (flows or []) if f.get('io_tp') == 'IN')
|
||||
co = sum(f['amount'] for f in (flows or []) if f.get('io_tp') == 'OUT')
|
||||
out[lb] = {'in': ci, 'out': co}
|
||||
return out
|
||||
|
||||
|
||||
# ---- 공개 API ----
|
||||
|
||||
def bootstrap(labels: list[str] | None = None) -> dict:
|
||||
"""FLOOR_YEAR ~ 올해 전수 조회로 캐시를 새로 만든다 (계좌×연도, 4×17≈68콜 ≈ 25초).
|
||||
|
||||
조기 종료 없음 — 잔고가 0으로 비었던 휴면 연도가 중간에 끼어도 그 앞 연도를 놓치지 않는다.
|
||||
⚠️ 1회성 CLI 전용. 렌더 경로에서 절대 호출하지 말 것.
|
||||
"""
|
||||
lbs = _labels(labels)
|
||||
this_year = _this_year()
|
||||
frozen: dict[str, dict] = {}
|
||||
current: dict[str, dict] = {}
|
||||
for lb in lbs:
|
||||
frozen[lb] = {}
|
||||
for year in range(FLOOR_YEAR, this_year + 1):
|
||||
y = _fetch_year(lb, year)
|
||||
time.sleep(PACE_SEC)
|
||||
if year == this_year:
|
||||
current[lb] = y
|
||||
elif not _is_empty(y):
|
||||
frozen[lb][year_key(year)] = y
|
||||
with _lock():
|
||||
d = _load() or {}
|
||||
d['frozen'] = {**(d.get('frozen') or {}), **frozen}
|
||||
cur = d.get('current') or {}
|
||||
if cur.get('year') != this_year:
|
||||
cur = {'year': this_year, 'labels': {}}
|
||||
cur['labels'] = {**(cur.get('labels') or {}), **current}
|
||||
d['current'] = cur
|
||||
d['seen'] = {**(d.get('seen') or {}), **_seen_now(lbs)}
|
||||
_save(d)
|
||||
return _load()
|
||||
|
||||
|
||||
def year_key(year: int) -> str:
|
||||
return str(year)
|
||||
|
||||
|
||||
def _seen_now(labels: list[str]) -> dict:
|
||||
"""현재 당일 입출금을 seen 형식으로. (CLI refresh/bootstrap 용 — kt00015 계좌당 1콜)"""
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
totals = _flow_totals(kc.get_cash_flow_all(None, labels))
|
||||
return {lb: {'date': today, **totals.get(lb, {'in': 0, 'out': 0})} for lb in labels}
|
||||
|
||||
|
||||
def refresh_current(labels: list[str], cash_flow_by_label: dict | None = None) -> None:
|
||||
"""올해 창만 재조회 (라벨당 1콜) + 연도 롤오버 시 작년을 확정값으로 이관.
|
||||
|
||||
cash_flow_by_label: 호출측이 이미 들고 있는 kt00015 당일 입출금.
|
||||
None이면 직접 조회. seen 기록에 쓴다 — kt00016 호출 **전** 시점의 값이어야
|
||||
그 사이 들어온 입금이 seen에 앞서 반영돼 영구 미갱신되는 일이 없다.
|
||||
"""
|
||||
lbs = _labels(labels)
|
||||
if not lbs:
|
||||
return
|
||||
if cash_flow_by_label is None:
|
||||
seen = _seen_now(lbs)
|
||||
else:
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
totals = _flow_totals(cash_flow_by_label)
|
||||
seen = {lb: {'date': today, **totals.get(lb, {'in': 0, 'out': 0})} for lb in lbs}
|
||||
|
||||
this_year = _this_year()
|
||||
with _lock():
|
||||
d = _load()
|
||||
if d is None:
|
||||
return # 부트스트랩 전 — 렌더 경로를 68콜로 막지 않는다
|
||||
cur = d['current']
|
||||
prev_year = cur.get('year')
|
||||
# 연도 롤오버: current에 담긴 작년치는 to_dt가 12/31이 아니라 미완결이므로
|
||||
# 전체 창(0101~1231)으로 다시 받아 확정 이관한다.
|
||||
if prev_year and prev_year != this_year:
|
||||
for lb in lbs:
|
||||
y = _fetch_year(lb, prev_year)
|
||||
time.sleep(PACE_SEC)
|
||||
if not _is_empty(y):
|
||||
d['frozen'].setdefault(lb, {})[year_key(prev_year)] = y
|
||||
cur = {'year': this_year, 'labels': {}}
|
||||
for lb in lbs:
|
||||
cur['labels'][lb] = _fetch_year(lb, this_year)
|
||||
time.sleep(PACE_SEC)
|
||||
cur['year'] = this_year
|
||||
d['current'] = cur
|
||||
d['seen'].update(seen)
|
||||
_save(d)
|
||||
|
||||
|
||||
def needs_refresh(labels: list[str], cash_flow_by_label: dict) -> bool:
|
||||
"""올해 창 재조회가 필요한지 판정 — 순수 캐시 읽기(키움 콜 0).
|
||||
|
||||
캐시가 없으면 False — 부트스트랩 미실행 상태에서 페이지 로드를 68콜로 막지 않는다.
|
||||
"""
|
||||
d = _load()
|
||||
if d is None:
|
||||
return False
|
||||
if d['current'].get('year') != _this_year():
|
||||
return True
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
totals = _flow_totals(cash_flow_by_label)
|
||||
for lb in labels:
|
||||
if lb not in totals:
|
||||
continue # owner 부분 갱신 — 조회 안 된 계좌는 판단 보류
|
||||
s = d['seen'].get(lb) or {}
|
||||
base = s if s.get('date') == today else {'in': 0, 'out': 0}
|
||||
if totals[lb]['in'] != base.get('in', 0) or totals[lb]['out'] != base.get('out', 0):
|
||||
return True
|
||||
ts = d.get('refreshed_at')
|
||||
if not ts:
|
||||
return True
|
||||
try:
|
||||
age = datetime.now(KST) - datetime.fromisoformat(ts)
|
||||
except Exception:
|
||||
return True
|
||||
return age > timedelta(days=STALE_DAYS)
|
||||
|
||||
|
||||
def get_principal(labels: list[str]) -> dict:
|
||||
"""캐시만 읽어 누적 원금 산출 (키움 콜 0).
|
||||
|
||||
반환 principal이 None이면 '미집계' — 요청 라벨 중 부트스트랩 안 된 계좌가 있다는 뜻.
|
||||
(0으로 떨어뜨리면 그 계좌 입금이 조용히 빠져 원금이 과소계상된다.)
|
||||
qty_flow=True는 주식 입출고(타사대체) 이력 — 현금 기준 원금이 과소계상됨을 알림.
|
||||
by_label은 계좌별 순입금 {label: 원} — 자세히보기 토스트용.
|
||||
"""
|
||||
miss = {'principal': None, 'cash_in': None, 'cash_out': None,
|
||||
'qty_flow': False, 'by_label': {}}
|
||||
d = _load()
|
||||
if d is None:
|
||||
return miss
|
||||
cur = d['current']['labels']
|
||||
total_in = total_out = 0
|
||||
qty_flow = False
|
||||
by_label: dict[str, int] = {}
|
||||
for lb in labels:
|
||||
if lb not in cur:
|
||||
return miss
|
||||
recs = list((d['frozen'].get(lb) or {}).values()) + [cur[lb]]
|
||||
lb_in = lb_out = 0
|
||||
for y in recs:
|
||||
lb_in += y.get('in', 0)
|
||||
lb_out += y.get('out', 0)
|
||||
qty_flow = qty_flow or bool(y.get('qin') or y.get('qout'))
|
||||
by_label[lb] = lb_in - lb_out
|
||||
total_in += lb_in
|
||||
total_out += lb_out
|
||||
return {
|
||||
'principal': total_in - total_out,
|
||||
'cash_in': total_in,
|
||||
'cash_out': total_out,
|
||||
'qty_flow': qty_flow,
|
||||
'by_label': by_label,
|
||||
}
|
||||
|
||||
|
||||
def get_flows(labels: list[str]) -> dict:
|
||||
"""입출금 내역 (최신순) — 캐시만 읽는다 (키움 콜 0).
|
||||
|
||||
반환:
|
||||
- rows: [{date, io('IN'|'OUT'), amount, rmrk, principal(bool), label}] 최신순
|
||||
- other_in / other_out: 원금 무관 입출금 합계 (배당·이자 등)
|
||||
- reconciled: 전 구간에서 적요 분류 합이 kt00016 합계와 일치했는지
|
||||
False면 새 적요가 등장해 분류가 어긋났다는 신호
|
||||
- available: 내역이 캐시에 있는지 (부트스트랩 전 or 조회 실패 시 False)
|
||||
"""
|
||||
d = _load()
|
||||
if d is None:
|
||||
return {'rows': [], 'other_in': 0, 'other_out': 0,
|
||||
'reconciled': True, 'available': False}
|
||||
cur = d['current']['labels']
|
||||
rows: list[dict] = []
|
||||
reconciled = True
|
||||
available = False
|
||||
for lb in labels:
|
||||
recs = list((d['frozen'].get(lb) or {}).values())
|
||||
if lb in cur:
|
||||
recs.append(cur[lb])
|
||||
for y in recs:
|
||||
flows = y.get('flows')
|
||||
if flows is None:
|
||||
continue
|
||||
available = True
|
||||
if y.get('rec') is False:
|
||||
reconciled = False
|
||||
for f in flows:
|
||||
rows.append({'date': f['d'], 'io': f['io'], 'amount': f['amt'],
|
||||
'rmrk': f['rk'], 'principal': f['pr'], 'label': lb})
|
||||
rows.sort(key=lambda r: (r['date'], r['label']), reverse=True)
|
||||
return {
|
||||
'rows': rows,
|
||||
'other_in': sum(r['amount'] for r in rows if r['io'] == 'IN' and not r['principal']),
|
||||
'other_out': sum(r['amount'] for r in rows if r['io'] == 'OUT' and not r['principal']),
|
||||
'reconciled': reconciled,
|
||||
'available': available,
|
||||
}
|
||||
|
||||
|
||||
# ---- CLI ----
|
||||
|
||||
def _show() -> int:
|
||||
d = _load()
|
||||
if d is None:
|
||||
print(f'캐시 없음: {STATE_FILE}\n → python3 scripts/investment_principal.py bootstrap')
|
||||
return 1
|
||||
from stock_portfolio_report import derive_owner
|
||||
|
||||
print(f'갱신: {d.get("refreshed_at")} (올해 창 {d["current"].get("year")})\n')
|
||||
lbs = list(d['current']['labels'].keys())
|
||||
bad = 0
|
||||
for lb in lbs:
|
||||
years = sorted((d['frozen'].get(lb) or {}).items())
|
||||
years.append((year_key(d['current']['year']), d['current']['labels'][lb]))
|
||||
print(f'===== {lb} =====')
|
||||
tin = tout = 0
|
||||
for yk, y in years:
|
||||
# 항등식: 순자산초 + 입금 − 출금 + 평가손익 = 순자산말
|
||||
err = y['nf'] + y['in'] - y['out'] + y['pl'] - y['nt']
|
||||
bad += 1 if err else 0
|
||||
tin += y['in']
|
||||
tout += y['out']
|
||||
q = f' 입고 {y["qin"]:,} 출고 {y["qout"]:,}' if (y['qin'] or y['qout']) else ''
|
||||
nf = len(y.get('flows') or []) if y.get('flows') is not None else None
|
||||
rc = {True: '분류OK', False: '분류불일치!', None: '내역없음'}[y.get('rec')]
|
||||
print(f' {yk}: 입금 {y["in"]:>12,} 출금 {y["out"]:>11,} '
|
||||
f'순자산 {y["nf"]:>12,}→{y["nt"]:>12,} 손익 {y["pl"]:>12,} '
|
||||
f'항등식오차 {err:,} 내역 {nf if nf is not None else "-":>3}행 {rc}{q}')
|
||||
print(f' >> 누적 입금 {tin:,} − 출금 {tout:,} = 순입금 {tin - tout:,}\n')
|
||||
|
||||
print('항등식 검증:', '전 행 오차 0 ✅' if not bad else f'⚠️ 오차 있는 행 {bad}개')
|
||||
fl = get_flows(lbs)
|
||||
print(f'적요 분류 검증: {"전 구간 일치 ✅" if fl["reconciled"] else "⚠️ 불일치 구간 있음 — 새 적요 확인 필요"}'
|
||||
f' (내역 {len(fl["rows"])}행, 원금무관 입금 {fl["other_in"]:,}원 / 출금 {fl["other_out"]:,}원)')
|
||||
|
||||
# 라이브 교차검증 — 원금 대비 총수익 vs 실제 순자산
|
||||
print('\n===== 원금 대비 총수익 (라이브 교차검증) =====')
|
||||
owners: dict[str, list[str]] = {}
|
||||
for lb in lbs:
|
||||
owners.setdefault(derive_owner(lb), []).append(lb)
|
||||
for owner, olbs in owners.items():
|
||||
pr = get_principal(olbs)
|
||||
net = 0
|
||||
for lb in olbs:
|
||||
net += sum(p['evlt_amt'] for p in kc.get_positions(lb)) + kc.get_balance(lb)['d2_entra']
|
||||
p = pr['principal']
|
||||
ret = net - p
|
||||
warn = ' ⚠️ 주식 입출고 있음(원금 과소계상)' if pr['qty_flow'] else ''
|
||||
print(f'[{owner}] 투자원금 {p:,}원 (입금 {pr["cash_in"]:,} − 출금 {pr["cash_out"]:,}){warn}')
|
||||
print(f' 순자산 {net:,}원 → 총수익 {ret:+,}원 ({ret / p * 100:+.2f}%)' if p else ' 원금 0')
|
||||
return 0 if not bad else 1
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
cmd = argv[1] if len(argv) > 1 else 'show'
|
||||
if cmd == 'bootstrap':
|
||||
lbs = _labels()
|
||||
n = len(lbs) * (_this_year() - FLOOR_YEAR + 1)
|
||||
print(f'{len(lbs)}계좌 × {_this_year() - FLOOR_YEAR + 1}년 = {n}콜 (약 {n * PACE_SEC:.0f}초)...')
|
||||
bootstrap()
|
||||
print(f'완료 → {STATE_FILE}\n')
|
||||
return _show()
|
||||
if cmd == 'refresh':
|
||||
refresh_current(_labels())
|
||||
print(f'올해 창 갱신 완료 → {STATE_FILE}\n')
|
||||
return _show()
|
||||
if cmd == 'show':
|
||||
return _show()
|
||||
print(__doc__)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main(sys.argv))
|
||||
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from pathlib import Path
|
||||
|
||||
from ipo_calendar_sync import (
|
||||
KST,
|
||||
NAVER_IPO_API_URL,
|
||||
_naver_api_rows,
|
||||
fetch,
|
||||
send_telegram,
|
||||
)
|
||||
|
||||
|
||||
STATE_FILE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/ipo_alerts.json')
|
||||
|
||||
# 온라인 일반 고객 기준. 고객등급·배정 여부에 따라 면제될 수 있다.
|
||||
# 확인이 끝난 증권사만 숫자를 두고, 미확인 증권사는 알림에서 추정하지 않는다.
|
||||
BROKER_ONLINE_FEES = {
|
||||
'대신증권': 2_000,
|
||||
'하나증권': 2_000,
|
||||
'미래에셋증권': 2_000,
|
||||
'삼성증권': 2_000,
|
||||
'KB증권': 1_500,
|
||||
'유진투자증권': 2_000,
|
||||
'한국투자증권': 2_000,
|
||||
'IBK투자증권': 0,
|
||||
}
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {'sent': {}}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def _int(value) -> int | None:
|
||||
try:
|
||||
return int(str(value).replace(',', '').strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _money(value: int | None) -> str:
|
||||
return '미확인' if value is None else f'{value:,}원'
|
||||
|
||||
|
||||
def _detail(ipo_code: str) -> dict:
|
||||
url = f'https://stock.naver.com/api/domestic/ipo/{ipo_code}/detail/info'
|
||||
return json.loads(fetch(url, encoding='utf-8'))
|
||||
|
||||
|
||||
def _date_range(start: str, end: str) -> str:
|
||||
return start if not end or start == end else f'{start} ~ {end}'
|
||||
|
||||
|
||||
def _required_deposit(total: int, ratio) -> int | None:
|
||||
try:
|
||||
pct = Decimal(str(ratio))
|
||||
except Exception:
|
||||
return None
|
||||
return int((Decimal(total) * pct / Decimal(100)).quantize(Decimal('1'), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _manager_blocks(row: dict, detail: dict) -> list[dict]:
|
||||
info = detail.get('registrationStatement') or {}
|
||||
ratio = info.get('sbscMrgnRatio') or '50'
|
||||
price = _int((detail.get('demandForecastingResult') or {}).get('fixPubPrice'))
|
||||
if price is None:
|
||||
price = _int(row.get('fixPubPrice'))
|
||||
|
||||
managers = detail.get('joinManagers') or []
|
||||
if not managers:
|
||||
names = [x.strip() for x in (row.get('orgNm') or '').split(',') if x.strip()]
|
||||
managers = [{'orgNm': name, 'sbscMinNum': None} for name in names]
|
||||
if not managers:
|
||||
managers = [{'orgNm': '미확인', 'sbscMinNum': None}]
|
||||
|
||||
blocks = []
|
||||
for manager in managers:
|
||||
broker = (manager.get('orgNm') or '미확인').strip()
|
||||
min_qty = _int(manager.get('sbscMinNum'))
|
||||
total = price * min_qty if price is not None and min_qty is not None else None
|
||||
deposit = _required_deposit(total, ratio) if total is not None else None
|
||||
fee = BROKER_ONLINE_FEES.get(broker)
|
||||
blocks.append({
|
||||
'broker': broker,
|
||||
'min_qty': min_qty,
|
||||
'price': price,
|
||||
'total': total,
|
||||
'ratio': ratio,
|
||||
'deposit': deposit,
|
||||
'fee': fee,
|
||||
})
|
||||
return blocks
|
||||
|
||||
|
||||
def format_alert(kind: str, row: dict, detail: dict) -> str:
|
||||
name = (row.get('compName') or (detail.get('ipoInfo') or {}).get('compName') or '미확인').strip()
|
||||
info = detail.get('ipoInfo') or {}
|
||||
if kind == 'subscription':
|
||||
heading = '[공모주 청약]'
|
||||
date_label = '청약일'
|
||||
date_value = _date_range(info.get('poStartDate') or row.get('poStartDate') or '', info.get('poEndDate') or row.get('poEndDate') or '')
|
||||
else:
|
||||
heading = '[신규 상장]'
|
||||
date_label = '상장일'
|
||||
date_value = info.get('lcalDate') or row.get('lcalDate') or ''
|
||||
|
||||
sections = []
|
||||
for block in _manager_blocks(row, detail):
|
||||
price = block['price']
|
||||
qty = block['min_qty']
|
||||
if block['deposit'] is not None and qty is not None:
|
||||
cost = f"{block['deposit']:,}원(최소 {qty:,}주)"
|
||||
elif price is not None:
|
||||
cost = '최소청약수량 미확인'
|
||||
else:
|
||||
cost = '미확정'
|
||||
fee = block['fee']
|
||||
cost += f" / 수수료 {_money(fee)}(온라인 일반 기준)"
|
||||
sections.append('\n'.join([
|
||||
heading,
|
||||
f'종목명 : {name}',
|
||||
f"증권사 : {block['broker']}",
|
||||
f'{date_label} : {date_value}',
|
||||
f'증거금 : {cost}',
|
||||
]))
|
||||
return '\n\n'.join(sections)
|
||||
|
||||
|
||||
GAHEE_CRED = Path('/Users/snowoyh/.openclaw/credentials/gahee_imessage.json')
|
||||
IPO_SUBSCRIBED = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/behive_ipo_subscribed.json')
|
||||
|
||||
|
||||
def gahee_listing_message(target: date, rows: list[tuple[dict, dict]] | None) -> tuple[list[str], str]:
|
||||
"""가희가 청약한 종목 중 target 상장분 → (ipoCode 목록, 문자 본문). 없으면 ([], '').
|
||||
상장일은 API 상장 목록 ∪ 청약기록 lcal_date(네이버 조회 실패해도 기록으로 보낸다)."""
|
||||
try:
|
||||
subscribed = json.loads(IPO_SUBSCRIBED.read_text())
|
||||
except Exception:
|
||||
return [], ''
|
||||
by_code = {(r.get('ipoCode') or r.get('itemCode') or '').strip(): (r, d) for r, d in (rows or [])}
|
||||
codes, blocks = [], []
|
||||
for code, rec in subscribed.items():
|
||||
broker = ((rec.get('by') or {}).get('gahee') or {}).get('broker')
|
||||
if not broker or (code not in by_code and rec.get('lcal_date') != target.isoformat()):
|
||||
continue
|
||||
row, detail = by_code.get(code, ({}, None))
|
||||
if detail is None:
|
||||
try:
|
||||
detail = _detail(code)
|
||||
except Exception:
|
||||
detail = {}
|
||||
price = next((b['price'] for b in _manager_blocks(row, detail) if b.get('price') is not None), None)
|
||||
lines = [f"[공모주 상장] 오늘 {rec.get('name') or row.get('compName') or code} 상장일이에요",
|
||||
f'· 청약 증권사: {broker}']
|
||||
if price is not None:
|
||||
lines.append(f'· 확정 공모가: {price:,}원')
|
||||
lines.append(f'· 09:00 거래 시작 — 배정 주식은 {broker} 앱에서 확인하세요')
|
||||
codes.append(code)
|
||||
blocks.append('\n'.join(lines))
|
||||
return codes, '\n\n'.join(blocks)
|
||||
|
||||
|
||||
def send_gahee_imessage(text: str) -> bool:
|
||||
"""가희 리마인더(gahee_reminder._send_imessage)와 같은 방식 — imessage 강제(sms 는 이 수신자에게 3전 3패).
|
||||
⚠️ rc=0 은 Messages 에 넘겼다는 뜻일 뿐 전달 확인이 아니다. 코디 셸에선 AppleEvents 거부로 안 되고 launchd 경유로만."""
|
||||
handle = json.loads(GAHEE_CRED.read_text()).get('handle')
|
||||
if not handle:
|
||||
return False
|
||||
p = subprocess.run(['imsg', 'send', '--to', handle, '--text', text, '--service', 'imessage'],
|
||||
capture_output=True, text=True, timeout=30)
|
||||
if p.returncode != 0:
|
||||
print(f'gahee imsg send rc={p.returncode}: {p.stderr[:200]}', file=sys.stderr)
|
||||
return p.returncode == 0
|
||||
|
||||
|
||||
def collect_for_date(kind: str, target: date) -> list[tuple[dict, dict]]:
|
||||
payload = json.loads(fetch(NAVER_IPO_API_URL, encoding='utf-8'))
|
||||
matches = []
|
||||
field = 'poStartDate' if kind == 'subscription' else 'lcalDate'
|
||||
for row in _naver_api_rows(payload):
|
||||
if row.get(field) != target.isoformat():
|
||||
continue
|
||||
code = (row.get('ipoCode') or row.get('itemCode') or '').strip()
|
||||
if not code:
|
||||
continue
|
||||
matches.append((row, _detail(code)))
|
||||
return matches
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('kind', choices=('subscription', 'listing'))
|
||||
parser.add_argument('--date', help='YYYY-MM-DD, 기본값은 오늘(KST)')
|
||||
parser.add_argument('--dry-run', action='store_true')
|
||||
args = parser.parse_args()
|
||||
|
||||
target = date.fromisoformat(args.date) if args.date else datetime.now(KST).date()
|
||||
try:
|
||||
rows = collect_for_date(args.kind, target)
|
||||
except Exception as exc:
|
||||
print(f'IPO alert source failed: {type(exc).__name__}: {exc}', file=sys.stderr)
|
||||
rows = None
|
||||
if args.kind == 'listing' and not args.dry_run:
|
||||
# 자산웹에서 청약완료한 종목을 상장일에 관심종목으로 등록. 알림 소스가 죽어도
|
||||
# 청약완료 기록의 상장일로 시도한다(그래서 위에서 바로 return 하지 않는다).
|
||||
listed = {(r.get('ipoCode') or r.get('itemCode') or '').strip() for r, _ in (rows or [])}
|
||||
try:
|
||||
from behive_web import register_listed_ipo_interests
|
||||
added = register_listed_ipo_interests(target.isoformat(), listed)
|
||||
if added:
|
||||
print(f'IPO interests added: {", ".join(added)}')
|
||||
except Exception as exc:
|
||||
print(f'IPO interests add failed: {type(exc).__name__}: {exc}', file=sys.stderr)
|
||||
if not args.dry_run:
|
||||
# 비하이브 '공모청약' 영상 요약 → 자산웹 공모주 탭. 새 영상 없으면 채널 페이지 1콜로 끝난다.
|
||||
try:
|
||||
from ipo_youtube_digest import run as run_ipo_youtube
|
||||
print(f'IPO youtube digest: {json.dumps(run_ipo_youtube(), ensure_ascii=False)}')
|
||||
except Exception as exc:
|
||||
print(f'IPO youtube digest failed: {type(exc).__name__}: {exc}', file=sys.stderr)
|
||||
if args.kind == 'listing':
|
||||
# 가희가 청약한 종목의 상장일 아침 iMessage(2026-10-07 관리자님 요청). 같은 날 여러 종목이면 한 통.
|
||||
try:
|
||||
codes, text = gahee_listing_message(target, rows)
|
||||
state = load_state()
|
||||
key = f'gahee_listing|{target.isoformat()}|{",".join(sorted(codes))}'
|
||||
if not codes:
|
||||
pass
|
||||
elif args.dry_run:
|
||||
print(f'[dry-run] 가희 상장 문자:\n{text}\n')
|
||||
elif key not in state.get('sent', {}):
|
||||
if send_gahee_imessage(text):
|
||||
state.setdefault('sent', {})[key] = datetime.now(KST).isoformat()
|
||||
save_state(state)
|
||||
print(f'gahee listing imessage sent: {",".join(codes)}')
|
||||
except Exception as exc:
|
||||
print(f'gahee listing imessage failed: {type(exc).__name__}: {exc}', file=sys.stderr)
|
||||
if rows is None:
|
||||
return 1
|
||||
|
||||
state = load_state()
|
||||
sent = state.setdefault('sent', {})
|
||||
delivered = 0
|
||||
skipped = 0
|
||||
for row, detail in rows:
|
||||
code = (row.get('ipoCode') or row.get('itemCode') or '').strip()
|
||||
key = f'{args.kind}|{target.isoformat()}|{code}'
|
||||
if key in sent and not args.dry_run:
|
||||
skipped += 1
|
||||
continue
|
||||
message = format_alert(args.kind, row, detail)
|
||||
if args.dry_run:
|
||||
print(message)
|
||||
print()
|
||||
continue
|
||||
if not send_telegram(message):
|
||||
print(f'IPO alert send failed: {key}', file=sys.stderr)
|
||||
return 1
|
||||
sent[key] = datetime.now(KST).isoformat()
|
||||
delivered += 1
|
||||
|
||||
if not args.dry_run:
|
||||
# 상태 파일이 무한히 커지지 않도록 최근 120일만 보존한다.
|
||||
cutoff = date.fromordinal(target.toordinal() - 120).isoformat()
|
||||
state['sent'] = {
|
||||
key: value for key, value in sent.items()
|
||||
if len(key.split('|')) < 2 or key.split('|')[1] >= cutoff
|
||||
}
|
||||
state['last_run_at'] = datetime.now(KST).isoformat()
|
||||
state['last_kind'] = args.kind
|
||||
state['last_target_date'] = target.isoformat()
|
||||
state['last_found'] = len(rows)
|
||||
state['last_delivered'] = delivered
|
||||
state['last_skipped'] = skipped
|
||||
save_state(state)
|
||||
|
||||
print(json.dumps({
|
||||
'kind': args.kind,
|
||||
'target_date': target.isoformat(),
|
||||
'found': len(rows),
|
||||
'delivered': delivered,
|
||||
'skipped': skipped,
|
||||
'dry_run': args.dry_run,
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -5,9 +5,10 @@ import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
from datetime import date, datetime, timedelta
|
||||
from html import unescape
|
||||
from pathlib import Path
|
||||
@@ -19,9 +20,19 @@ WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
STATE_DIR = WORKSPACE / 'state'
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE = STATE_DIR / 'ipo_calendar_sync.json'
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
TELEGRAM_ACCOUNT = 'stock'
|
||||
SUBSCRIPTION_URL = 'https://www.38.co.kr/html/fund/index.htm?o=k'
|
||||
NAVER_IPO_URL = 'https://finance.naver.com/sise/ipo.naver'
|
||||
SOURCE_LABEL = '네이버 금융 IPO'
|
||||
LISTING_URL = 'https://www.38.co.kr/html/fund/index.htm?o=nw'
|
||||
NAVER_IPO_API_URL = 'https://stock.naver.com/api/domestic/market/ipo/progress'
|
||||
SOURCE_LABEL = '네이버페이 증권 IPO'
|
||||
LISTING_LOOKBACK_DAYS = 14
|
||||
|
||||
|
||||
class IpoParserError(RuntimeError):
|
||||
def __init__(self, errors: list[str]):
|
||||
self.errors = errors
|
||||
super().__init__('IPO 파서 전부 실패: ' + ' | '.join(errors))
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -32,6 +43,7 @@ class EventSpec:
|
||||
end_date: date # inclusive
|
||||
brokers: str
|
||||
source_url: str
|
||||
source_label: str = SOURCE_LABEL
|
||||
|
||||
@property
|
||||
def summary(self) -> str:
|
||||
@@ -49,7 +61,7 @@ class EventSpec:
|
||||
f'종목명: {self.name}\n'
|
||||
f'증권사: {self.brokers or "미확인"}\n'
|
||||
f'{label}: {date_str}\n'
|
||||
f'기준: {SOURCE_LABEL}\n'
|
||||
f'기준: {self.source_label}\n'
|
||||
f'출처: {self.source_url}'
|
||||
)
|
||||
|
||||
@@ -65,6 +77,38 @@ def run(cmd: list[str]) -> str:
|
||||
return p.stdout
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
try:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
except Exception as e:
|
||||
print(f'telegram cfg load failed: {e}', file=sys.stderr)
|
||||
return False
|
||||
if not chat_ids:
|
||||
print('no telegram chat_ids', file=sys.stderr)
|
||||
return False
|
||||
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
body = urllib.parse.urlencode({
|
||||
'chat_id': chat_id,
|
||||
'text': text[:4000],
|
||||
'disable_web_page_preview': 'true',
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=body, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=15) as response:
|
||||
if response.status != 200:
|
||||
ok = False
|
||||
except Exception as e:
|
||||
print(f'telegram send failed: {e}', file=sys.stderr)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
def fetch(url: str, encoding: str = 'euc-kr') -> str:
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
@@ -106,108 +150,167 @@ def parse_date_range(text: str) -> tuple[date, date] | None:
|
||||
return date(y, m1, d1), date(y, m2, d2)
|
||||
|
||||
|
||||
def parse_single_date(text: str) -> date | None:
|
||||
m = re.match(r'(\d{4})\.(\d{2})\.(\d{2})$', text.strip())
|
||||
if not m:
|
||||
return None
|
||||
y, mm, dd = map(int, m.groups())
|
||||
return date(y, mm, dd)
|
||||
|
||||
|
||||
def extract_next_data_json(html: str) -> dict:
|
||||
m = re.search(r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>', html, re.S)
|
||||
if not m:
|
||||
return {}
|
||||
try:
|
||||
return json.loads(m.group(1))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def extract_naver_ipo_detail(detail_url: str) -> dict:
|
||||
html = fetch(detail_url, encoding='utf-8')
|
||||
data = extract_next_data_json(html)
|
||||
queries = data.get('props', {}).get('pageProps', {}).get('dehydratedState', {}).get('queries', [])
|
||||
for q in queries:
|
||||
result = q.get('state', {}).get('data', {}).get('result', {})
|
||||
ipo_info = result.get('ipoInfo')
|
||||
if ipo_info:
|
||||
return ipo_info
|
||||
return {}
|
||||
|
||||
|
||||
def extract_naver_ipo_entries() -> list[dict]:
|
||||
html = fetch(NAVER_IPO_URL, encoding='auto')
|
||||
entries = []
|
||||
seen = set()
|
||||
for m in re.finditer(r'<div class="item_area" id="([^"]+)">.*?<a href="(https://m\.stock\.naver\.com/ipo/[^"]+)"[^>]*>(.*?)</a>', html, re.S):
|
||||
code = m.group(1).strip()
|
||||
detail_url = m.group(2).strip()
|
||||
name = clean_text(m.group(3))
|
||||
if not code or code in seen or not name:
|
||||
continue
|
||||
seen.add(code)
|
||||
entries.append({'code': code, 'name': name, 'detail_url': detail_url})
|
||||
return entries
|
||||
|
||||
|
||||
def extract_brokers_from_naver_detail(detail_url: str) -> tuple[dict, str]:
|
||||
html = fetch(detail_url, encoding='utf-8')
|
||||
data = extract_next_data_json(html)
|
||||
queries = data.get('props', {}).get('pageProps', {}).get('dehydratedState', {}).get('queries', [])
|
||||
ipo_info = {}
|
||||
brokers = []
|
||||
for q in queries:
|
||||
result = q.get('state', {}).get('data', {}).get('result', {})
|
||||
if isinstance(result, dict) and result.get('ipoInfo'):
|
||||
ipo_info = result.get('ipoInfo') or {}
|
||||
join_managers = result.get('joinManagers') or []
|
||||
for item in join_managers:
|
||||
def _naver_api_rows(payload: dict) -> list[dict]:
|
||||
"""네이버 IPO progress API의 단계별 배열을 종목 단위로 합친다."""
|
||||
list_keys = (
|
||||
'examinationList', 'demandForecastingList', 'forecastingCompleteList',
|
||||
'subscriptionList', 'subscriptionCompleteList', 'listingList',
|
||||
)
|
||||
if not isinstance(payload, dict) or not any(key in payload for key in list_keys):
|
||||
raise ValueError('네이버 IPO API 스키마 불일치')
|
||||
rows: dict[str, dict] = {}
|
||||
for key in list_keys:
|
||||
items = payload.get(key) or []
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f'네이버 IPO API {key} 타입 불일치')
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
name = (item.get('orgNm') or '').strip()
|
||||
if name and name not in brokers:
|
||||
brokers.append(name)
|
||||
break
|
||||
return ipo_info, ','.join(brokers)
|
||||
code = (item.get('ipoCode') or item.get('itemCode') or '').strip()
|
||||
name = (item.get('compName') or item.get('stockName') or '').strip()
|
||||
if not code or not name:
|
||||
continue
|
||||
rows[code] = {**rows.get(code, {}), **item}
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
def parse_subscription_events_from(cutoff: date) -> list[EventSpec]:
|
||||
events = []
|
||||
for entry in extract_naver_ipo_entries():
|
||||
info, brokers = extract_brokers_from_naver_detail(entry['detail_url'])
|
||||
def parse_naver_api_events_from(cutoff: date) -> list[EventSpec]:
|
||||
payload = json.loads(fetch(NAVER_IPO_API_URL, encoding='utf-8'))
|
||||
events: list[EventSpec] = []
|
||||
for info in _naver_api_rows(payload):
|
||||
code = info.get('ipoCode') or info.get('itemCode')
|
||||
name = (info.get('compName') or info.get('stockName') or '').strip()
|
||||
brokers = (info.get('orgNm') or '').strip()
|
||||
source_url = f'https://stock.naver.com/ipo/{code}'
|
||||
|
||||
start_raw = (info.get('poStartDate') or '').strip()
|
||||
end_raw = (info.get('poEndDate') or '').strip()
|
||||
if not start_raw or not end_raw or '미정' in start_raw or '미정' in end_raw:
|
||||
continue
|
||||
if start_raw and end_raw:
|
||||
try:
|
||||
start_date = date.fromisoformat(start_raw)
|
||||
end_date = date.fromisoformat(end_raw)
|
||||
except ValueError:
|
||||
continue
|
||||
if end_date <= cutoff:
|
||||
continue
|
||||
events.append(EventSpec('subscription', info.get('compName') or entry['name'], start_date, end_date, brokers, entry['detail_url']))
|
||||
pass
|
||||
else:
|
||||
if end_date >= cutoff:
|
||||
events.append(EventSpec(
|
||||
'subscription', name, start_date, end_date, brokers, source_url
|
||||
))
|
||||
|
||||
listed_raw = (info.get('lcalDate') or info.get('listingDate') or '').strip()
|
||||
if listed_raw:
|
||||
try:
|
||||
listed_date = date.fromisoformat(listed_raw)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
if listed_date >= cutoff:
|
||||
events.append(EventSpec(
|
||||
'listing', name, listed_date, listed_date, brokers, source_url
|
||||
))
|
||||
return events
|
||||
|
||||
|
||||
def parse_listed_events_from(cutoff: date) -> list[EventSpec]:
|
||||
events = []
|
||||
for entry in extract_naver_ipo_entries():
|
||||
info, brokers = extract_brokers_from_naver_detail(entry['detail_url'])
|
||||
listed_raw = (info.get('lcalDate') or info.get('listingDate') or info.get('listDate') or '').strip()
|
||||
if not listed_raw or '미정' in listed_raw:
|
||||
def _table_by_summary(html: str, summary: str) -> str:
|
||||
match = re.search(
|
||||
r'<table[^>]*summary=["\']' + re.escape(summary) + r'["\'][^>]*>(.*?)</table>',
|
||||
html,
|
||||
re.S | re.I,
|
||||
)
|
||||
if not match:
|
||||
raise ValueError(f'38커뮤니케이션 테이블 없음: {summary}')
|
||||
return match.group(0)
|
||||
|
||||
|
||||
def parse_38_subscription_html(html: str, cutoff: date) -> list[EventSpec]:
|
||||
events: list[EventSpec] = []
|
||||
for cols in parse_html_rows(_table_by_summary(html, '공모주 청약일정')):
|
||||
if len(cols) < 6 or cols[0] == '종목명':
|
||||
continue
|
||||
parsed = parse_date_range(cols[1])
|
||||
if not parsed:
|
||||
continue
|
||||
start_date, end_date = parsed
|
||||
if end_date < cutoff:
|
||||
continue
|
||||
events.append(EventSpec(
|
||||
'subscription', cols[0], start_date, end_date, cols[5], SUBSCRIPTION_URL,
|
||||
source_label='38커뮤니케이션 IPO',
|
||||
))
|
||||
return events
|
||||
|
||||
|
||||
def parse_38_listing_html(html: str, cutoff: date) -> list[EventSpec]:
|
||||
events: list[EventSpec] = []
|
||||
for cols in parse_html_rows(_table_by_summary(html, '신규상장종목')):
|
||||
if len(cols) < 2 or cols[0] == '기업명':
|
||||
continue
|
||||
try:
|
||||
d = date.fromisoformat(listed_raw)
|
||||
listed_date = date.fromisoformat(cols[1].replace('/', '-'))
|
||||
except ValueError:
|
||||
continue
|
||||
if d <= cutoff:
|
||||
if listed_date < cutoff:
|
||||
continue
|
||||
events.append(EventSpec('listing', info.get('compName') or entry['name'], d, d, brokers, entry['detail_url']))
|
||||
events.append(EventSpec(
|
||||
'listing', cols[0], listed_date, listed_date, '', LISTING_URL,
|
||||
source_label='38커뮤니케이션 IPO',
|
||||
))
|
||||
return events
|
||||
|
||||
|
||||
def parse_38_events_from(cutoff: date) -> list[EventSpec]:
|
||||
subscriptions = parse_38_subscription_html(fetch(SUBSCRIPTION_URL), cutoff)
|
||||
listing_cutoff = cutoff - timedelta(days=LISTING_LOOKBACK_DAYS)
|
||||
listings = parse_38_listing_html(fetch(LISTING_URL), listing_cutoff)
|
||||
# 보조 소스는 스팩 종목명이 주 소스와 크게 달라 과거 건을 중복 생성할 수 있다.
|
||||
listings = [
|
||||
ev for ev in listings
|
||||
if ev.start_date == cutoff or '스팩' not in ev.name
|
||||
]
|
||||
return subscriptions + listings
|
||||
|
||||
|
||||
def collect_ipo_events(cutoff: date) -> tuple[list[EventSpec], str, list[str]]:
|
||||
"""네이버 API를 우선 사용하고 실패·0건이면 38 파서로 자동 전환한다.
|
||||
|
||||
네이버 API는 상장 당일 장 시작 후 종목을 listingList에서 제거하므로, 주 파서가
|
||||
정상이어도 최근 14일 신규상장을 38 목록으로 보강한다.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
try:
|
||||
events = parse_naver_api_events_from(cutoff)
|
||||
except Exception as e:
|
||||
events = []
|
||||
errors.append(f'naver_api: {e}')
|
||||
|
||||
parser_used = 'naver_api'
|
||||
if not events:
|
||||
if not errors:
|
||||
errors.append('naver_api: 수집 결과 0건')
|
||||
try:
|
||||
events = parse_38_events_from(cutoff)
|
||||
parser_used = '38_fallback'
|
||||
except Exception as e:
|
||||
errors.append(f'38_fallback: {e}')
|
||||
raise IpoParserError(errors) from e
|
||||
else:
|
||||
try:
|
||||
listing_cutoff = cutoff - timedelta(days=LISTING_LOOKBACK_DAYS)
|
||||
recent_listings = parse_38_listing_html(fetch(LISTING_URL), listing_cutoff)
|
||||
existing_keys = {ev.state_key for ev in events}
|
||||
events.extend(
|
||||
ev for ev in recent_listings
|
||||
if ev.state_key not in existing_keys
|
||||
and ev.start_date <= cutoff
|
||||
and (ev.start_date == cutoff or '스팩' not in ev.name)
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f'38_same_day: {e}')
|
||||
|
||||
deduped = {ev.state_key: ev for ev in events}
|
||||
return list(deduped.values()), parser_used, errors
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
if STATE_FILE.exists():
|
||||
try:
|
||||
@@ -221,6 +324,59 @@ def save_state(state: dict):
|
||||
STATE_FILE.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
def update_parser_health(
|
||||
state: dict,
|
||||
status: str,
|
||||
errors: list[str],
|
||||
total_found: int,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""파서 상태가 바뀔 때만 알리고, 실패한 발송은 다음 실행에서 재시도한다."""
|
||||
now_iso = datetime.now(KST).isoformat()
|
||||
previous = state.get('parser_health_status')
|
||||
state['parser_health_status'] = status
|
||||
state['parser_health_checked_at'] = now_iso
|
||||
state['parser_errors'] = errors
|
||||
|
||||
if status == 'healthy':
|
||||
state['parser_last_success_at'] = now_iso
|
||||
else:
|
||||
state['parser_last_failure_at'] = now_iso
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
message = None
|
||||
if status == 'failed' and (
|
||||
previous != status or state.get('parser_alerted_status') != status
|
||||
):
|
||||
message = (
|
||||
'[IPO 일정] 파서 전체 실패\n'
|
||||
+ '\n'.join(errors)
|
||||
+ '\n캘린더 동기화를 중단했고 기존 일정은 유지했습니다.'
|
||||
)
|
||||
elif status == 'degraded' and (
|
||||
previous != status or state.get('parser_alerted_status') != status
|
||||
):
|
||||
source = '38커뮤니케이션으로 자동 전환했습니다.'
|
||||
if not any(error.startswith('naver_api:') for error in errors):
|
||||
source = '네이버 주 파서는 정상이며 당일 상장 보강 파서만 실패했습니다.'
|
||||
message = (
|
||||
'[IPO 일정] 파서 일부 실패\n'
|
||||
+ '\n'.join(errors)
|
||||
+ f'\n{source}\n수집 결과: {total_found}건\n기존 일정 자동 삭제는 중지했습니다.'
|
||||
)
|
||||
elif status == 'healthy' and previous in {'degraded', 'failed'}:
|
||||
message = (
|
||||
'[IPO 일정] 파서 복구\n'
|
||||
f'네이버 IPO API와 당일 상장 보강 파서가 정상입니다.\n수집 결과: {total_found}건'
|
||||
)
|
||||
|
||||
if message and send_telegram(message):
|
||||
state['parser_alerted_status'] = status
|
||||
state['parser_alert_sent_at'] = now_iso
|
||||
|
||||
|
||||
def event_key_from_summary(summary: str) -> tuple[str, str] | None:
|
||||
summary = (summary or '').strip()
|
||||
if summary.startswith('[공모청약] '):
|
||||
@@ -314,13 +470,39 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
cutoff = datetime.now(KST).date()
|
||||
events = parse_subscription_events_from(cutoff) + parse_listed_events_from(cutoff)
|
||||
events.sort(key=lambda e: (e.start_date, e.kind, e.name))
|
||||
|
||||
state = load_state()
|
||||
previous_parser_used = state.get('parser_used')
|
||||
try:
|
||||
events, parser_used, parser_errors = collect_ipo_events(cutoff)
|
||||
except IpoParserError as e:
|
||||
update_parser_health(state, 'failed', e.errors, 0, dry_run=args.dry_run)
|
||||
state['last_run_at'] = datetime.now(KST).isoformat()
|
||||
state['parser_used'] = 'failed'
|
||||
if not args.dry_run:
|
||||
save_state(state)
|
||||
print(json.dumps({
|
||||
'cutoff_after': cutoff.isoformat(),
|
||||
'parser_used': 'failed',
|
||||
'parser_errors': e.errors,
|
||||
'total_found': 0,
|
||||
'dry_run': args.dry_run,
|
||||
}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
events.sort(key=lambda e: (e.start_date, e.kind, e.name))
|
||||
parser_health = 'degraded' if parser_errors else 'healthy'
|
||||
update_parser_health(
|
||||
state, parser_health, parser_errors, len(events), dry_run=args.dry_run
|
||||
)
|
||||
state['parser_used'] = parser_used
|
||||
if not args.dry_run:
|
||||
# 알림 상태를 먼저 저장해 이후 캘린더 작업 실패 시 같은 경보가 반복되지 않게 한다.
|
||||
save_state(state)
|
||||
|
||||
end_date = max((ev.end_date for ev in events), default=cutoff) + timedelta(days=1)
|
||||
# 사이트 스크래핑 실패 시 모든 일정이 삭제되는 사고 방지 — events 비면 cleanup 스킵
|
||||
existing = {} if not events else fetch_existing_events(cutoff + timedelta(days=1), end_date)
|
||||
listing_cutoff = cutoff - timedelta(days=LISTING_LOOKBACK_DAYS)
|
||||
existing = {} if not events else fetch_existing_events(listing_cutoff, end_date)
|
||||
created = []
|
||||
updated = []
|
||||
recreated = []
|
||||
@@ -369,15 +551,21 @@ def main():
|
||||
else:
|
||||
unchanged += 1
|
||||
|
||||
# 사이트에서 사라진 일정은 캘린더에서도 삭제 (events 비면 위에서 existing이 빈 dict라 자동으로 스킵됨)
|
||||
# 보조 소스는 종목명 표기가 다를 수 있어 failover 실행 중에는 stale 삭제를 하지 않는다.
|
||||
# 장기간 장애 후 복구 첫 실행도 오탐 삭제를 막고, 주 파서가 2회 연속 정상일 때만 정리한다.
|
||||
cleanup_allowed = parser_used == 'naver_api' and previous_parser_used == 'naver_api'
|
||||
if cleanup_allowed:
|
||||
for key, existing_list in existing.items():
|
||||
if key in event_keys:
|
||||
continue
|
||||
kind, name = key.split('|', 1)
|
||||
for stale in existing_list:
|
||||
old_start, old_end = event_date_range(stale)
|
||||
# 소급 조회는 누락 상장 보강용이다. 이미 지난 일정은 삭제하지 않는다.
|
||||
if old_end and date.fromisoformat(old_end) < cutoff:
|
||||
continue
|
||||
delete_event(stale['id'], dry_run=args.dry_run)
|
||||
deleted.append(key)
|
||||
old_start, old_end = event_date_range(stale)
|
||||
old_date = old_start if old_start == old_end or not old_end else f'{old_start}~{old_end}'
|
||||
changes.append({'kind': kind, 'name': name, 'action': 'deleted', 'event_id': stale['id'], 'old_date': old_date})
|
||||
|
||||
@@ -386,8 +574,9 @@ def main():
|
||||
if not args.dry_run:
|
||||
save_state(state)
|
||||
|
||||
print(json.dumps({'cutoff_after': cutoff.isoformat(), 'total_found': len(events), 'newly_created': len(created), 'updated': len(updated), 'recreated': len(recreated), 'deleted': len(deleted), 'duplicates_removed': len(duplicates_removed), 'unchanged': unchanged, 'dry_run': args.dry_run, 'changes': changes}, ensure_ascii=False))
|
||||
print(json.dumps({'cutoff_after': cutoff.isoformat(), 'parser_used': parser_used, 'parser_errors': parser_errors, 'total_found': len(events), 'newly_created': len(created), 'updated': len(updated), 'recreated': len(recreated), 'deleted': len(deleted), 'duplicates_removed': len(duplicates_removed), 'unchanged': unchanged, 'dry_run': args.dry_run, 'changes': changes}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
raise SystemExit(main())
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""비하이브 '[🐝 공모청약]' 영상 → 종목별 청약 의견 요약 (자산웹 공모주 탭 표시용).
|
||||
|
||||
흐름: 채널 영상 목록(youtube_briefing_digest 재사용) → 제목 '공모청약' 필터 → 처리 안 한 영상만
|
||||
자막 → LLM 이 '지금 진행·예정 공모주 목록' 중 영상이 다룬 종목별로 요약 → state 에 영상 단위 저장.
|
||||
behive_web 은 state 파일만 읽는다(렌더 경로 LLM·네트워크 0).
|
||||
|
||||
⚠️ 영상 하나가 여러 종목을 다룬다(예: 멜콘 영상 = 1주차 3종목) → 제목 해시태그로 매칭하지 않고
|
||||
후보 목록을 LLM 에 주고 ipoCode 로 돌려받는다. 목록에 없는 코드는 버린다.
|
||||
⚠️ 자막은 업로드 직후 없을 수 있다 → 실패는 기록만 하고 다음 실행에서 재시도, MAX_ATTEMPTS 후 포기.
|
||||
트리거: ipo_alert.py(stock.ipo-alert 08:30·10:00)가 끝에 run() 을 부른다. 새 트리거 없음.
|
||||
|
||||
CLI: python3 ipo_youtube_digest.py [run|show] [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, '/Users/snowoyh/.openclaw/workspace/scripts')
|
||||
import youtube_briefing_digest as ytd # noqa: E402
|
||||
from ipo_calendar_sync import KST, NAVER_IPO_API_URL, _naver_api_rows, fetch # noqa: E402
|
||||
|
||||
STATE_FILE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace/state/ipo_youtube_summaries.json')
|
||||
VIDEOS_URL = ytd.CHANNELS['behive']['videos_url']
|
||||
TITLE_FILTER = '공모청약'
|
||||
# launchd PATH 의 /opt/homebrew/bin/openclaw 는 node22 라 안 뜬다 — node24 래퍼를 절대경로로.
|
||||
OPENCLAW_BIN = str(Path.home() / '.local/bin/openclaw')
|
||||
LLM_MODEL = 'openai/gpt-5.6-sol'
|
||||
LLM_TIMEOUT_SEC = 180
|
||||
MAX_ATTEMPTS = 6 # 자막 미생성·LLM 실패 재시도 상한 (하루 2회 실행 → 약 3일)
|
||||
KEEP_DAYS = 120 # 상장 끝난 지 오래된 영상 기록 정리
|
||||
VERDICTS = {'positive': '청약 매력 높음', 'neutral': '중립·수요예측 확인', 'negative': '청약 부담'}
|
||||
|
||||
|
||||
def load_state() -> dict:
|
||||
try:
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
except Exception:
|
||||
return {'videos': {}}
|
||||
|
||||
|
||||
def save_state(state: dict) -> None:
|
||||
tmp = STATE_FILE.with_suffix('.json.tmp')
|
||||
tmp.write_text(json.dumps(state, ensure_ascii=False, indent=2))
|
||||
tmp.replace(STATE_FILE)
|
||||
|
||||
|
||||
def _candidates(today: date) -> list[dict]:
|
||||
"""요약 대상 후보 = 아직 상장 안 했거나 상장 2주 이내인 공모주(청약 전·중·후 모두)."""
|
||||
rows = _naver_api_rows(json.loads(fetch(NAVER_IPO_API_URL, encoding='utf-8')))
|
||||
floor = (today - timedelta(days=14)).isoformat()
|
||||
out = []
|
||||
for r in rows:
|
||||
code = (r.get('ipoCode') or r.get('itemCode') or '').strip()
|
||||
name = (r.get('compName') or '').strip()
|
||||
if not code or not name:
|
||||
continue
|
||||
if (r.get('lcalDate') or '9999') < floor:
|
||||
continue
|
||||
out.append({'ipo_code': code, 'name': name, 'po_start': r.get('poStartDate') or '',
|
||||
'po_end': r.get('poEndDate') or '', 'lcal_date': r.get('lcalDate') or '',
|
||||
'hope': f"{r.get('hopePubStart') or '?'}~{r.get('hopePubEnd') or '?'}",
|
||||
'fix': r.get('fixPubPrice') or ''})
|
||||
return out
|
||||
|
||||
|
||||
def _call_llm(prompt: str) -> str:
|
||||
cmd = [OPENCLAW_BIN, 'capability', 'model', 'run', '--prompt', prompt, '--model', LLM_MODEL, '--json']
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=LLM_TIMEOUT_SEC)
|
||||
if p.returncode != 0:
|
||||
raise RuntimeError(f'LLM rc={p.returncode}: {p.stderr[-300:]}')
|
||||
data = json.loads(p.stdout[p.stdout.index('{'):])
|
||||
text = ((data.get('outputs') or [{}])[0].get('text') or '').strip()
|
||||
if not data.get('ok') or not text:
|
||||
raise RuntimeError(f'LLM 응답 비정상: {str(data)[:300]}')
|
||||
return text
|
||||
|
||||
|
||||
def _prompt(title: str, transcript: str, cands: list[dict]) -> str:
|
||||
cand_lines = '\n'.join(
|
||||
f'- {c["ipo_code"]} {c["name"]} (청약 {c["po_start"]}~{c["po_end"]}, 희망공모가 {c["hope"]}원'
|
||||
+ (f', 확정공모가 {c["fix"]}원' if c['fix'] else '') + ')' for c in cands)
|
||||
return f"""아래는 유튜브 채널 '비하이브 투자자문'의 공모주 청약 영상 자막(자동생성, 오타 많음)이다.
|
||||
영상이 **구체적으로 분석한** 종목만 골라 종목별로 요약하라. 일정만 언급하고 지나간 종목은 제외.
|
||||
종목은 반드시 아래 후보 목록의 코드로 지정한다(자막의 종목명 오타는 후보명으로 맞춰 판단). 후보에 없으면 제외.
|
||||
|
||||
[후보]
|
||||
{cand_lines}
|
||||
|
||||
[영상 제목] {title}
|
||||
[자막]
|
||||
{transcript}
|
||||
|
||||
출력은 JSON 배열 하나만(설명·코드펜스 금지):
|
||||
[{{"ipo_code":"A123456","verdict":"positive|neutral|negative","verdict_text":"영상 결론 한 구절(15자 이내)","points":["핵심 3~4개, 각 40자 이내"]}}]
|
||||
- verdict: 영상 화자의 청약 의견. 긍정=positive, 유보·수요예측 보고 판단=neutral, 부정·부담=negative
|
||||
- 자막 숫자는 받아쓰기 오류가 잦다(예: 74,000→7,400). 공모가는 후보 정보가 정답이니 그 값을 쓰고, 다른 수치도 앞뒤가 안 맞으면 빼라
|
||||
- points: 사업 한 줄 / 실적 추이(수치) / 유통가능물량(%) / 공모가·밸류 부담 등 화자가 짚은 장단점. 영상에 없는 내용 지어내지 말 것
|
||||
- 해당 종목이 없으면 []"""
|
||||
|
||||
|
||||
def _parse(text: str, cands: list[dict]) -> list[dict]:
|
||||
m = re.search(r'\[.*\]', text, re.S)
|
||||
arr = json.loads(m.group(0)) if m else []
|
||||
names = {c['ipo_code']: c['name'] for c in cands}
|
||||
out = []
|
||||
for it in arr:
|
||||
code = str(it.get('ipo_code') or '').strip()
|
||||
if code not in names:
|
||||
continue
|
||||
verdict = it.get('verdict') if it.get('verdict') in VERDICTS else 'neutral'
|
||||
points = [str(p).strip() for p in (it.get('points') or []) if str(p).strip()][:4]
|
||||
out.append({'ipo_code': code, 'name': names[code], 'verdict': verdict,
|
||||
'verdict_text': str(it.get('verdict_text') or VERDICTS[verdict]).strip()[:30],
|
||||
'points': points})
|
||||
return out
|
||||
|
||||
|
||||
def run(dry_run: bool = False) -> dict:
|
||||
"""새 공모청약 영상만 요약. 반환: {new, summarized, failed}."""
|
||||
now = datetime.now(KST)
|
||||
state = load_state()
|
||||
videos = state.setdefault('videos', {})
|
||||
listing = [v for v in ytd.fetch_channel_videos(VIDEOS_URL) if TITLE_FILTER in v.get('title', '')]
|
||||
todo = [v for v in listing
|
||||
if not videos.get(v['video_id'], {}).get('done')
|
||||
and videos.get(v['video_id'], {}).get('attempts', 0) < MAX_ATTEMPTS]
|
||||
result = {'new': len(todo), 'summarized': 0, 'failed': 0}
|
||||
if not todo:
|
||||
return result
|
||||
cands = _candidates(now.date())
|
||||
for v in todo:
|
||||
vid = v['video_id']
|
||||
rec = videos.setdefault(vid, {'title': v['title'], 'url': ytd.WATCH_URL.format(vid=vid), 'attempts': 0})
|
||||
try:
|
||||
transcript, status = ytd.fetch_transcript(vid)
|
||||
if status != 'ok':
|
||||
raise RuntimeError(f'자막 {status}')
|
||||
stocks = _parse(_call_llm(_prompt(v['title'], transcript, cands)), cands)
|
||||
except Exception as e:
|
||||
rec['attempts'] = rec.get('attempts', 0) + 1
|
||||
rec['last_error'] = f'{type(e).__name__}: {e}'[:300]
|
||||
result['failed'] += 1
|
||||
print(f'ipo yt {vid} 실패({rec["attempts"]}): {rec["last_error"]}', file=sys.stderr)
|
||||
continue
|
||||
published = ytd.get_publish_kst(vid)
|
||||
rec.update({'done': True, 'stocks': stocks, 'summarized_at': now.isoformat(timespec='seconds'),
|
||||
'published_kst': published.isoformat() if published else None})
|
||||
rec.pop('last_error', None)
|
||||
result['summarized'] += 1
|
||||
if dry_run:
|
||||
print(json.dumps({vid: rec}, ensure_ascii=False, indent=2))
|
||||
# 오래된 기록 정리 — 목록에서 빠졌고 요약 시각이 KEEP_DAYS 넘은 것
|
||||
cutoff = (now - timedelta(days=KEEP_DAYS)).isoformat()
|
||||
for vid in [k for k, r in videos.items() if (r.get('summarized_at') or now.isoformat()) < cutoff]:
|
||||
videos.pop(vid)
|
||||
if not dry_run:
|
||||
save_state(state)
|
||||
return result
|
||||
|
||||
|
||||
def summaries_by_code() -> dict[str, list[dict]]:
|
||||
"""behive_web 용: ipoCode → [{video_id,title,url,published_kst,verdict,verdict_text,points,tagged}].
|
||||
제목 해시태그에 그 종목이 있는 전용 영상을 앞에, 그다음 최신순."""
|
||||
out: dict[str, list[dict]] = {}
|
||||
for vid, rec in load_state().get('videos', {}).items():
|
||||
for s in rec.get('stocks') or []:
|
||||
out.setdefault(s['ipo_code'], []).append({
|
||||
'video_id': vid, 'title': rec.get('title', ''), 'url': rec.get('url', ''),
|
||||
'published_kst': rec.get('published_kst') or rec.get('summarized_at') or '',
|
||||
'verdict': s['verdict'], 'verdict_text': s['verdict_text'], 'points': s['points'],
|
||||
'tagged': f'#{s["name"]}' in rec.get('title', ''),
|
||||
})
|
||||
for lst in out.values():
|
||||
lst.sort(key=lambda x: x['published_kst'], reverse=True)
|
||||
lst.sort(key=lambda x: not x['tagged'])
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('cmd', nargs='?', default='run', choices=('run', 'show'))
|
||||
ap.add_argument('--dry-run', action='store_true')
|
||||
args = ap.parse_args()
|
||||
if args.cmd == 'show':
|
||||
print(json.dumps(summaries_by_code(), ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
print(json.dumps(run(args.dry_run), ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -13,9 +13,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import http.client
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
@@ -36,6 +39,7 @@ ENDPOINT_RKINFO = '/api/dostk/rkinfo' # 순위정보 — ka10023 거래량급
|
||||
TR_DEPOSIT = 'kt00001' # 예수금상세현황요청
|
||||
TR_ACNT_EVAL = 'kt00004' # 계좌평가현황요청 (acnt_nm·brch_nm 포함)
|
||||
TR_TRUST_TRADE = 'kt00015' # 위탁종합거래내역요청 (tp='1' 입출금 — 당일 추가입금/인출 추출용)
|
||||
TR_ACNT_RETURN = 'kt00016' # 일별계좌수익률상세현황요청 (기간내총입금·총출금 — 투자원금 산출용)
|
||||
TR_POSITIONS = 'kt00018' # 계좌평가잔고내역요청 (종목별 상세, 수수료·세금 포함)
|
||||
TR_TRADE_DIARY = 'ka10170' # 당일매매일지요청 (round-trip·풀매도 포함, 잔고에 없는 거래도 잡힘)
|
||||
TR_ORDER_EXEC = 'kt00007' # 계좌별주문체결내역상세 (주문번호·시각·체결가·체결수량 단위, REST/영웅문 출처 구분)
|
||||
@@ -48,6 +52,8 @@ TR_MINUTE_CHART = 'ka10080' # 주식분봉차트조회 (1/3/5/10/15/30/45/60분
|
||||
TR_INVESTOR_FLOW = 'ka10059' # 종목별투자자기관별 (외국인·기관 일별 매매)
|
||||
|
||||
STOCK_CODES_CACHE = STATE_DIR / 'stock_codes.json'
|
||||
_HTTP_TRANSIENT_RETRIES = 2
|
||||
_HTTP_TRANSIENT_BACKOFF_SEC = 0.8
|
||||
|
||||
|
||||
def load_credentials() -> dict:
|
||||
@@ -86,6 +92,15 @@ def _http_post_full(url: str, body: dict, headers: dict | None = None) -> tuple[
|
||||
if headers:
|
||||
h.update(headers)
|
||||
req = urllib.request.Request(url, data=data, method='POST', headers=h)
|
||||
transient_errors = (
|
||||
ConnectionResetError,
|
||||
TimeoutError,
|
||||
socket.timeout,
|
||||
urllib.error.URLError,
|
||||
http.client.RemoteDisconnected,
|
||||
http.client.IncompleteRead,
|
||||
)
|
||||
for attempt in range(_HTTP_TRANSIENT_RETRIES + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
resp_headers = {k.lower(): v for k, v in r.headers.items()}
|
||||
@@ -93,6 +108,12 @@ def _http_post_full(url: str, body: dict, headers: dict | None = None) -> tuple[
|
||||
except urllib.error.HTTPError as e:
|
||||
body_text = e.read().decode('utf-8', 'ignore')
|
||||
raise RuntimeError(f'HTTP {e.code} {url}: {body_text}') from e
|
||||
except transient_errors:
|
||||
if attempt >= _HTTP_TRANSIENT_RETRIES:
|
||||
raise
|
||||
time.sleep(_HTTP_TRANSIENT_BACKOFF_SEC * (attempt + 1))
|
||||
|
||||
raise RuntimeError(f'HTTP POST failed unexpectedly: {url}')
|
||||
|
||||
|
||||
def _token_cache_path(label: str) -> Path:
|
||||
@@ -411,25 +432,39 @@ def get_trade_journal_all(base_dt: str | None = None, labels: list[str] | None =
|
||||
return {a['label']: get_trade_journal(a['label'], base_dt) for a in accs}
|
||||
|
||||
|
||||
def get_cash_flow(account_label: str, base_dt: str | None = None) -> list[dict]:
|
||||
"""당일 입출금 내역 (kt00015 tp='1').
|
||||
def get_cash_flow(account_label: str, base_dt: str | None = None,
|
||||
strt_dt: str | None = None, end_dt: str | None = None) -> list[dict]:
|
||||
"""입출금 내역 (kt00015 tp='1').
|
||||
|
||||
base_dt: YYYYMMDD (None이면 KST 오늘). 시작/종료 동일 → 그날치만.
|
||||
strt_dt/end_dt: 둘 다 주면 기간 조회 (base_dt 무시).
|
||||
⚠️ **조회기간 1년 제한** — 넘기면 `[2000](501058:조회기간은 1년 이내로만 가능합니다.)`.
|
||||
다년치는 연도별로 쪼개 호출한다 (investment_principal.py 참조).
|
||||
tp='1'(입출금)·gds_tp='0'(전체)로 매매·환전 등은 제외. KRW만 필터.
|
||||
|
||||
⚠️ tp='1' 입금에는 **배당금·예탁금이용료(이자)·수익분배금·공모주환불금·단주매각대금**이
|
||||
섞여 들어온다. 이건 '내가 넣은 돈'이 아니므로 투자원금 합산에 그대로 쓰면 과대계상된다.
|
||||
kt00016의 기간내총입금/총출금은 이걸 이미 제외한 순수 외부 이체액이다 (전 계좌·연도 실증).
|
||||
적요 기준 분류는 `investment_principal.is_principal_flow`.
|
||||
|
||||
반환 행:
|
||||
- date: 'YYYYMMDD' (trde_dt)
|
||||
- time: 'HH:MM:SS' (proc_tm 앞 8자리)
|
||||
- io_tp: 'IN' | 'OUT'
|
||||
- amount: 거래금액 (원, 양수)
|
||||
- signed: +amount (입금) / -amount (출금) — 합산으로 net cash flow 산출
|
||||
- rmrk: 적요명 (예: '타사대체입금', '은행이체출금')
|
||||
- rmrk: 적요명 (예: '이체입금(지급결제)', '배당금입금', '이체출금')
|
||||
- balance: 거래 후 예수금잔고 (entra_remn)
|
||||
"""
|
||||
if strt_dt and end_dt:
|
||||
fr, to = strt_dt, end_dt
|
||||
else:
|
||||
if base_dt is None:
|
||||
base_dt = datetime.now(KST).strftime('%Y%m%d')
|
||||
fr = to = base_dt
|
||||
body = {
|
||||
'strt_dt': base_dt,
|
||||
'end_dt': base_dt,
|
||||
'strt_dt': fr,
|
||||
'end_dt': to,
|
||||
'tp': '1',
|
||||
'stk_cd': '',
|
||||
'crnc_cd': '',
|
||||
@@ -458,6 +493,7 @@ def get_cash_flow(account_label: str, base_dt: str | None = None) -> list[dict]:
|
||||
continue
|
||||
proc_tm = (it.get('proc_tm') or '').strip()
|
||||
out.append({
|
||||
'date': (it.get('trde_dt') or '').strip(),
|
||||
'time': proc_tm[:8],
|
||||
'io_tp': io_tp,
|
||||
'amount': amount,
|
||||
@@ -477,6 +513,38 @@ def get_cash_flow_all(base_dt: str | None = None, labels: list[str] | None = Non
|
||||
return {a['label']: get_cash_flow(a['label'], base_dt) for a in accs}
|
||||
|
||||
|
||||
def get_period_return(account_label: str, fr_dt: str, to_dt: str) -> dict:
|
||||
"""기간 계좌수익률 상세 (kt00016) — 기간내 총입금·총출금 포함.
|
||||
|
||||
⚠️ **조회기간 1년 제한** — 넘기면 `[2000](505221:조회기간은 1년이내로만 가능합니다.)`.
|
||||
전체기간 투자원금은 연도별 창으로 쪼개 누적해야 한다 (investment_principal.py 참조).
|
||||
|
||||
⚠️ 응답의 `invt_bsamt`는 "투자원금**평잔**"(기간 평균잔고)이라 누적 투자원금이 아니다 —
|
||||
이름이 비슷해 오용하기 쉬우니 반환하지 않는다. 누적 원금은 cash_in − cash_out.
|
||||
|
||||
계좌 개설 전 기간은 에러 없이 전 필드 0으로 응답한다.
|
||||
항등식 `net_fr + cash_in − cash_out + eval_pl == net_to` 가 성립 (자체 검증용).
|
||||
|
||||
반환:
|
||||
- cash_in/cash_out: 기간내 총입금/총출금 (원)
|
||||
- qty_in/qty_out: 기간내 총입고/총출고 (주식 대체 — 0이 아니면 현금 기준 원금이 과소계상)
|
||||
- net_fr/net_to: 순자산액계 기초/기말
|
||||
- eval_pl: 평가손익
|
||||
"""
|
||||
resp = _call(account_label, TR_ACNT_RETURN, {'fr_dt': fr_dt, 'to_dt': to_dt})
|
||||
return {
|
||||
'label': account_label,
|
||||
'cash_in': _to_int(resp.get('termin_tot_trns')), # 기간내총입금
|
||||
'cash_out': _to_int(resp.get('termin_tot_pymn')), # 기간내총출금
|
||||
'qty_in': _to_int(resp.get('termin_tot_inq')), # 기간내총입고
|
||||
'qty_out': _to_int(resp.get('termin_tot_outq')), # 기간내총출고
|
||||
'net_fr': _to_int(resp.get('tot_amt_fr')), # 순자산액계_초
|
||||
'net_to': _to_int(resp.get('tot_amt_to')), # 순자산액계_말
|
||||
'eval_pl': _to_int(resp.get('evltv_prft')), # 평가손익
|
||||
'raw': resp,
|
||||
}
|
||||
|
||||
|
||||
def get_order_executions(account_label: str, base_dt: str | None = None) -> list[dict]:
|
||||
"""계좌별 주문체결내역 (kt00007) — 주문번호·시각 단위 행 리스트.
|
||||
|
||||
@@ -616,6 +684,11 @@ def get_open_orders(account_label: str, code: str = '',
|
||||
'order_price': _to_int(it.get('ord_pric')),
|
||||
'unfilled_qty': _to_int(it.get('oso_qty')),
|
||||
'order_type': (it.get('trde_tp') or '').strip(),
|
||||
# 스톱지정가(trde_tp=28) 주문의 조건단가. 일반 주문은 0.
|
||||
# 실측 확인: ka10075 응답에 stop_pric 로 실려 온다 (2026-07-30).
|
||||
'stop_price': _to_int(it.get('stop_pric')),
|
||||
# 현재가 — 조건단가까지 얼마 남았는지 계산용. 부호 포함 문자열('-4805')이라 abs.
|
||||
'cur_price': abs(_to_int(it.get('cur_prc'))),
|
||||
'exchange': (it.get('stex_tp_txt') or '').strip(),
|
||||
'exchange_code': ex_code,
|
||||
'routing_suffix': _EXCHANGE_CODE_TO_SUFFIX.get(ex_code, ''),
|
||||
@@ -723,11 +796,13 @@ def get_stock_quote(code: str, account_label: str | None = None, exchange: str =
|
||||
}
|
||||
|
||||
|
||||
WATCHLIST_BATCH_MAX = 40 # ka10095 한 콜 종목 수 한도 — 초과 시 "허용된 요청 개수 초과[1634]" (2026-06-17, universe 120개에서 발견)
|
||||
|
||||
|
||||
def get_watchlist_quotes(codes: list[str], account_label: str | None = None,
|
||||
exchange: str = 'AL') -> dict[str, dict]:
|
||||
"""ka10095 관심종목정보요청 — 다종목 시세 batch (한 콜로 N개).
|
||||
"""ka10095 관심종목정보요청 — 다종목 시세 batch. 종목 수가 한도(WATCHLIST_BATCH_MAX) 초과 시 자동 분할 호출.
|
||||
|
||||
19종목 ~0.05s. ka10001×N 대비 rate limit 영향 없음.
|
||||
반환: {clean_code: {price, change, change_pct, name}}. 빈 codes면 빈 dict.
|
||||
"""
|
||||
clean_codes = [_clean_code(c) for c in codes if c]
|
||||
@@ -735,8 +810,35 @@ def get_watchlist_quotes(codes: list[str], account_label: str | None = None,
|
||||
return {}
|
||||
label = account_label or _default_account_label()
|
||||
suffix = f'_{exchange}' if exchange in ('NX', 'AL') else ''
|
||||
stk_cd_param = '|'.join(f'{c}{suffix}' for c in clean_codes)
|
||||
resp = _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO)
|
||||
out: dict[str, dict] = {}
|
||||
for i in range(0, len(clean_codes), WATCHLIST_BATCH_MAX):
|
||||
chunk = clean_codes[i:i + WATCHLIST_BATCH_MAX]
|
||||
stk_cd_param = '|'.join(f'{c}{suffix}' for c in chunk)
|
||||
out.update(_parse_watchlist_rows(_watchlist_batch_call(label, stk_cd_param)))
|
||||
return out
|
||||
|
||||
|
||||
def _watchlist_batch_call(label: str, stk_cd_param: str) -> dict:
|
||||
"""ka10095 1콜 + 429 재시도.
|
||||
|
||||
behive_web 은 패널 렌더 1회에 이 TR 을 최대 7콜(보유 KRX/NX/AL · 워치리스트 · NXT 마크
|
||||
판정 · phantom · 미보유 미체결) 병렬 발사하는데 키움 유량은 초당 5건이라 일부가 429 로
|
||||
튕긴다. ⚠️ 튕긴 콜은 예외가 호출측에서 로그만 남고 삼켜져 **그 조각이 화면에서 조용히
|
||||
빈다** — 실측 `NXT positions price fetch failed` 25건, 그때마다 보유종목 NXT 컬럼이
|
||||
통째로 사라진다(NXT 실거래가 있는 삼성전자·SK하이닉스만 눈에 띈다).
|
||||
⚠️ ka10001·ka10004 처럼 `_QUOTE_GATE` 로 직렬화하면 7콜이 줄서서 렌더가 1초 이상
|
||||
느려지므로, 정상 경로는 병렬 그대로 두고 튕긴 콜만 백오프 후 1회 다시 친다.
|
||||
"""
|
||||
try:
|
||||
return _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO)
|
||||
except RuntimeError as e:
|
||||
if not _is_quote_rate_limit(str(e)):
|
||||
raise
|
||||
time.sleep(_QUOTE_BACKOFF_SEC)
|
||||
return _call(label, TR_WATCHLIST_INFO, {'stk_cd': stk_cd_param}, endpoint=ENDPOINT_STKINFO)
|
||||
|
||||
|
||||
def _parse_watchlist_rows(resp: dict) -> dict[str, dict]:
|
||||
out: dict[str, dict] = {}
|
||||
for row in resp.get('atn_stk_infr') or []:
|
||||
raw_code = (row.get('stk_cd') or '').strip()
|
||||
@@ -755,11 +857,16 @@ def get_watchlist_quotes(codes: list[str], account_label: str | None = None,
|
||||
'high': abs(_to_int(row.get('high_pric') or '0')),
|
||||
'low': abs(_to_int(row.get('low_pric') or '0')),
|
||||
'volume': _to_int(row.get('trde_qty') or '0'),
|
||||
# 거래소가 계산한 가격제한폭. 기준가(전일종가)로 정해져 장중 불변.
|
||||
# 제한폭은 기준가의 호가단위로 절사한 뒤 ±하는 규칙이라 직접 계산하면
|
||||
# ETF·가격대별 호가단위 표를 재현해야 해 틀릴 여지가 있다 → 응답값 그대로 쓴다.
|
||||
'upper_limit': abs(_to_int(row.get('upl_pric') or '0')),
|
||||
'lower_limit': abs(_to_int(row.get('lst_pric') or '0')),
|
||||
}
|
||||
return out
|
||||
|
||||
|
||||
def get_quote_book(code: str, account_label: str | None = None) -> dict:
|
||||
def get_quote_book(code: str, account_label: str | None = None) -> dict: # noqa: E302
|
||||
"""주식호가 조회 (ka10004) — 10단계 매도/매수 호가 + 잔량.
|
||||
|
||||
매매 아님 — 조회 전용. 호가창 표시·체결 가능성 판단에 사용.
|
||||
@@ -949,18 +1056,21 @@ def get_daily_candles(code: str, count: int = 250, base_dt: str | None = None,
|
||||
|
||||
|
||||
def get_minute_candles(code: str, tic_scope: int = 5, today_only: bool = True,
|
||||
account_label: str | None = None) -> list[dict]:
|
||||
account_label: str | None = None, exchange: str = 'KRX') -> list[dict]:
|
||||
"""주식 분봉 차트 (ka10080). 수정주가 기준.
|
||||
|
||||
tic_scope: 1/3/5/10/15/30/45/60 분. 한 호출에 ~900봉 (5분봉 ≈ 11일치).
|
||||
today_only: True 면 오늘 봉만 추출.
|
||||
exchange: 'KRX'=KRX 단독(기본) / 'NX'=NXT 단독 / 'AL'=통합(KRX→NXT 연속).
|
||||
NX/AL은 stk_cd에 suffix를 붙여 NXT 시간대(15:30~20:00)에도 분봉이 이어진다.
|
||||
|
||||
반환: 시간 오름차순 list of dict — {date, time, open, high, low, close, volume}
|
||||
date='YYYYMMDD', time='HHMMSS'.
|
||||
"""
|
||||
label = account_label or _default_account_label()
|
||||
suffix = f'_{exchange}' if exchange in ('NX', 'AL') else ''
|
||||
body = {
|
||||
'stk_cd': _clean_code(code),
|
||||
'stk_cd': _clean_code(code) + suffix,
|
||||
'tic_scope': str(tic_scope),
|
||||
'upd_stkpc_tp': '1',
|
||||
}
|
||||
@@ -1103,6 +1213,12 @@ def refresh_stock_codes(account_label: str | None = None) -> dict:
|
||||
- nxt_enable: bool — ka10099 응답 nxtEnable=='Y' 변환. NXT 가드 정확화에 사용
|
||||
- state: str — 종목상태 (예: '관리종목', '정리매매', '증거금100%')
|
||||
- order_warning: str — 투자유의 (0/1/2/3/4/5, ka10099 명세 참조)
|
||||
- etf_etn: bool — ETF/ETN 여부 (2026-09-21 추가). **KRX 애프터마켓(16:00~20:00)에서
|
||||
ETF/ETN 은 거래되지 않는다**(2026-09-21 실측: TIGER 200 거래량·호가가 15:35 이후 완전
|
||||
고정). 자산웹이 애프터마켓에 이들만 잠그려면 판별 수단이 필요한데, KOSPI/KOSDAQ 조회
|
||||
응답에는 ETF 구분이 없다. 그래서 ETF/ETN 전용 시장을 따로 조회해 코드로 표시한다.
|
||||
⚠️ 종목명 프리픽스(KODEX/TIGER…) 추측을 쓰지 않는 이유 — 신규 운용사 브랜드가 생기면
|
||||
조용히 틀리고, 매매 게이트라 정확도가 우선이다.
|
||||
"""
|
||||
label = account_label or _default_account_label()
|
||||
url = base_url() + ENDPOINT_STKINFO
|
||||
@@ -1136,6 +1252,40 @@ def refresh_stock_codes(account_label: str | None = None) -> dict:
|
||||
time.sleep(0.15)
|
||||
else:
|
||||
break
|
||||
|
||||
# ETF/ETN 표시 — 전용 시장을 훑어 코드 집합을 만들고 위에서 담은 항목에 플래그만 얹는다.
|
||||
# ETF/ETN 은 KOSPI/KOSDAQ 조회에도 이미 들어 있으므로 **새 항목을 추가하지 않는다**.
|
||||
# ⚠️ 여기서 실패해도 캐시 저장은 계속한다 — 플래그 하나 때문에 4300종목 캐시를
|
||||
# 통째로 잃으면 종목명 검색·NXT 판정이 다 멈춘다. 플래그가 없으면 호출측이
|
||||
# 보수적 기본값으로 떨어질 뿐이다.
|
||||
# ⚠️ ka10099 유량은 초당 5건이고 위 페이지네이션이 이미 버스트를 쌓아놨다 — 0.15s 간격으로
|
||||
# 이어 붙이면 마지막 시장이 429 로 튕긴다(2026-09-21 실측: mrkt_tp=90 8종목 누락).
|
||||
# 빠진 시장의 ETF/ETN 은 플래그가 False 로 남아 애프터마켓에 열려버리므로 재시도한다.
|
||||
etf_codes: set[str] = set()
|
||||
for mrkt_tp in ('8', '60', '70', '90'): # 8:ETF, 60:ETN, 70:손실제한ETN, 90:변동성ETN
|
||||
for attempt in range(2):
|
||||
time.sleep(0.5 if attempt == 0 else 1.5)
|
||||
try:
|
||||
resp, _ = _http_post_full(url, {'mrkt_tp': mrkt_tp},
|
||||
auth_headers(label, tr_id=TR_STOCK_LIST))
|
||||
if resp.get('return_code', 0) != 0:
|
||||
if _is_quote_rate_limit(str(resp.get('return_msg') or '')):
|
||||
continue
|
||||
break
|
||||
for it in (resp.get('list') or []):
|
||||
c = _clean_code(it.get('code', ''))
|
||||
if c:
|
||||
etf_codes.add(c)
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 0 and _is_quote_rate_limit(str(e)):
|
||||
continue
|
||||
print(f'[refresh_stock_codes] ETF/ETN 표시 실패 (mrkt_tp={mrkt_tp}): {e}',
|
||||
file=sys.stderr)
|
||||
break
|
||||
for info in result.values():
|
||||
info['etf_etn'] = info['code'] in etf_codes
|
||||
|
||||
STOCK_CODES_CACHE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STOCK_CODES_CACHE.write_text(json.dumps({
|
||||
'refreshed_at': datetime.now(KST).isoformat(),
|
||||
@@ -1272,6 +1422,22 @@ def main():
|
||||
print(f"{info['name']} ({info['code']}) · {info.get('market','?')}")
|
||||
return 0
|
||||
if cmd == 'refresh-codes':
|
||||
# --if-stale N: 캐시가 N일보다 최신이면 건너뛴다(정기 실행용).
|
||||
# ⚠️ 이 캐시의 nxt_enable 은 자산웹의 'NXT 거래 종목' 판정 근거다(behive_web._is_nxt_traded).
|
||||
# 낡으면 새로 NXT 편입된 종목을 놓쳐 🔥 배지·거래가능 초록점이 틀어진다(금액엔 영향 없음).
|
||||
# lazy 갱신은 '모르는 종목명 검색' 때만 돌아, 알던 종목만 다루면 영영 안 바뀐다.
|
||||
stale_days = 0.0
|
||||
if '--if-stale' in sys.argv:
|
||||
try:
|
||||
stale_days = float(sys.argv[sys.argv.index('--if-stale') + 1])
|
||||
except (IndexError, ValueError):
|
||||
print('usage: refresh-codes [--if-stale <days>]', file=sys.stderr)
|
||||
return 2
|
||||
if stale_days > 0 and STOCK_CODES_CACHE.exists():
|
||||
age_days = (time.time() - STOCK_CODES_CACHE.stat().st_mtime) / 86400
|
||||
if age_days < stale_days:
|
||||
print(f'종목코드 캐시 {age_days:.1f}일 — {stale_days:g}일 미만이라 갱신 생략')
|
||||
return 0
|
||||
stocks = refresh_stock_codes()
|
||||
print(f"종목코드 캐시 갱신 완료: {len(stocks)}종목 → {STOCK_CODES_CACHE}")
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""자산웹 손익차트 과거 구간 채우기 — 키움 kt00002(일별추정예탁자산현황) → state/net_worth_history.json.
|
||||
|
||||
portfolio_daily_snapshot 은 2026-05-01 부터만 쓸 수 있는 형식이라(4월은 예수금 없는 옛 형식) 그 이전
|
||||
차트가 비어 있다. 키움 계좌별 일별 추정예탁자산으로 그 앞 구간을 채운다. 조회 전용, 1회성 CLI.
|
||||
|
||||
- 조회기간 한 번에 6개월 미만(`501067`) → 170일 창으로 쪼갠다. 계좌당 3~4콜.
|
||||
- kt00002 는 주말·휴장일도 행이 온다 → KOSPI 일별종가가 있는 날(sim index_history)만 남긴다.
|
||||
market_holidays.json 은 2026년부터라 2025 휴장일을 못 거른다.
|
||||
- 값 기준이 스냅샷과 다르다(KRX 종가 − 추정 매도비용 ≈0.18%, 스냅샷은 20:10 NXT 가격) →
|
||||
겹치는 첫날(스냅샷 첫 날짜)의 비율로 owner 별 스케일을 맞춰 경계일 단차를 없앤다.
|
||||
- 입출금은 investment_principal 캐시(kt00015, 키움 콜 0)에서 — 스냅샷 cash_in 과 같은 의미
|
||||
(배당·이자 포함 전체 입출금).
|
||||
|
||||
CLI: python3 net_worth_backfill.py [--from YYYY-MM-DD] [--dry-run]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
import investment_principal as ip # noqa: E402
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
STATE = Path(__file__).resolve().parent.parent / 'state'
|
||||
SNAPSHOT_FILE = STATE / 'portfolio_daily_snapshot.json'
|
||||
INDEX_FILE = STATE / 'sim' / 'index_history.json'
|
||||
OUT_FILE = STATE / 'net_worth_history.json'
|
||||
OWNERS = {'본인': ['일반', 'ISA'], '가희': ['가희_일반', '가희_ISA']}
|
||||
WINDOW_DAYS = 170
|
||||
|
||||
|
||||
def _first_snapshot_date(snap: dict, owner: str) -> str | None:
|
||||
"""스냅샷에서 차트가 쓰는 형식(holdings+deposit)이 시작되는 날짜."""
|
||||
return next((d for d in sorted(snap) if 'holdings' in ((snap.get(d) or {}).get(owner) or {})), None)
|
||||
|
||||
|
||||
def _fetch_account(label: str, start: date, end: date) -> dict[str, int]:
|
||||
"""{YYYY-MM-DD: 추정예탁자산}. 계좌 개설 전 구간은 '내역 없음' 에러라 빈 창으로 친다."""
|
||||
out: dict[str, int] = {}
|
||||
cur = start
|
||||
while cur <= end:
|
||||
w_end = min(cur + timedelta(days=WINDOW_DAYS), end)
|
||||
try:
|
||||
r = kc._call_paginated(label, 'kt00002',
|
||||
{'start_dt': cur.strftime('%Y%m%d'), 'end_dt': w_end.strftime('%Y%m%d')},
|
||||
'daly_prsm_dpst_aset_amt_prst')
|
||||
for x in r.get('daly_prsm_dpst_aset_amt_prst') or []:
|
||||
v = kc._to_int(x.get('prsm_dpst_aset_amt'))
|
||||
if v:
|
||||
d = x['dt']
|
||||
out[f'{d[:4]}-{d[4:6]}-{d[6:]}'] = v
|
||||
except RuntimeError as e:
|
||||
if '506476' not in str(e): # 506476 = 내역 없음(개설 전)
|
||||
raise
|
||||
time.sleep(0.4)
|
||||
cur = w_end + timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--from', dest='frm', default='2025-01-01')
|
||||
ap.add_argument('--dry-run', action='store_true')
|
||||
a = ap.parse_args()
|
||||
|
||||
snap = json.loads(SNAPSHOT_FILE.read_text())
|
||||
idx = json.loads(INDEX_FILE.read_text()).get('KOSPI') or {}
|
||||
trading = {f'{d[:4]}-{d[4:6]}-{d[6:]}' for d in idx}
|
||||
start = date.fromisoformat(a.frm)
|
||||
if f'{start:%Y-%m-%d}' < min(trading):
|
||||
print(f'⚠️ 거래일 달력(KOSPI)이 {min(trading)} 부터라 그 이전은 거를 수 없다', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
days: dict[str, dict] = {}
|
||||
scale: dict[str, float] = {}
|
||||
for owner, labels in OWNERS.items():
|
||||
first = _first_snapshot_date(snap, owner)
|
||||
if not first:
|
||||
print(f'{owner}: 스냅샷 없음 — 건너뜀', file=sys.stderr)
|
||||
continue
|
||||
end = date.fromisoformat(first) # 겹치는 첫날까지 받아 스케일 기준으로 쓴다
|
||||
per_acct = {lb: _fetch_account(lb, start, end) for lb in labels}
|
||||
# 기준일(스냅샷 첫날)은 휴장일일 수 있어(2026-05-01 근로자의 날) 거래일 필터는 저장 행에만 건다.
|
||||
total = {d: sum(per_acct[lb].get(d, 0) for lb in labels) for d in set().union(*per_acct.values())}
|
||||
o_snap = snap[first][owner]
|
||||
snap_net = sum(int(h.get('eval_value', 0) or 0) for h in o_snap['holdings'].values()) + int(o_snap.get('deposit', 0) or 0)
|
||||
if not total.get(first):
|
||||
print(f'{owner}: 기준일 {first} kt00002 값 없음 — 중단', file=sys.stderr)
|
||||
return 1
|
||||
scale[owner] = snap_net / total[first]
|
||||
flows: dict[str, dict] = {}
|
||||
for f in ip.get_flows(labels)['rows']:
|
||||
d = f'{f["date"][:4]}-{f["date"][4:6]}-{f["date"][6:]}'
|
||||
k = 'cash_in' if f['io'] == 'IN' else 'cash_out'
|
||||
flows.setdefault(d, {'cash_in': 0, 'cash_out': 0})[k] += int(f['amount'])
|
||||
n = 0
|
||||
for d in sorted(total):
|
||||
if d >= first or d not in trading:
|
||||
continue
|
||||
rec = {'net': round(total[d] * scale[owner])}
|
||||
rec.update(flows.get(d) or {})
|
||||
days.setdefault(d, {})[owner] = rec
|
||||
n += 1
|
||||
print(f'{owner}: {n}일 ({min(days)} ~ {first} 직전), 스케일 {scale[owner]:.5f} (기준 {first} 스냅샷 {snap_net:,} / 키움 {total[first]:,})')
|
||||
|
||||
doc = {'source': 'kiwoom kt00002', 'fetched_at': datetime.now(KST).isoformat(timespec='seconds'),
|
||||
'scale': scale, 'days': dict(sorted(days.items()))}
|
||||
if a.dry_run:
|
||||
print('dry-run — 저장 안 함')
|
||||
return 0
|
||||
tmp = OUT_FILE.with_suffix('.tmp')
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=1))
|
||||
os.replace(tmp, OUT_FILE)
|
||||
print(f'저장: {OUT_FILE} ({len(days)}일)')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -50,6 +50,11 @@ def _pct(p: float, digits: int = 2) -> str:
|
||||
return f'{sign}{p:.{digits}f}%'
|
||||
|
||||
|
||||
def _num(p) -> str:
|
||||
"""계단 비율 표기 — 10.0 은 '10', 10.5 는 '10.5' 로. 불필요한 .0 을 지운다."""
|
||||
return f'{float(p or 0):g}'
|
||||
|
||||
|
||||
def format_card(request: dict, market_data: dict, card_id: str,
|
||||
estimate: Optional[dict] = None,
|
||||
budget_conversion: Optional[dict] = None,
|
||||
@@ -66,6 +71,12 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
type_marker = f' {cfg["warning_emoji"]} 시장가'
|
||||
elif request['order_type'] == 'AGGRESSIVE_LIMIT':
|
||||
type_marker = f' {cfg["warning_emoji"]} 공격적 지정가'
|
||||
elif request['order_type'] == 'STOP_LIMIT':
|
||||
type_marker = f' {cfg["warning_emoji"]} 스톱지정가(예약)'
|
||||
elif request['order_type'] == 'TRAILING_STOP':
|
||||
type_marker = f' {cfg["warning_emoji"]} 트레일링 스톱(예약)'
|
||||
elif request['order_type'] == 'TRAILING_ARM':
|
||||
type_marker = f' {cfg["warning_emoji"]} 예약 트레일링(발동 대기)'
|
||||
amend_marker = ' ✏️ 수정됨' if amended else ''
|
||||
|
||||
lines = [f'{side_emoji} {_md_bold(side_word + " 미리보기")} [#{card_id}]{type_marker}{spouse_marker}{amend_marker}', '']
|
||||
@@ -83,6 +94,66 @@ def format_card(request: dict, market_data: dict, card_id: str,
|
||||
ratio = (request['price'] - market_data['prev_close']) / market_data['prev_close'] * 100
|
||||
price_part += f' (전일종가 {_pct(ratio)})'
|
||||
lines.append(f'{marker} 가격: {price_part}')
|
||||
elif request['order_type'] == 'STOP_LIMIT':
|
||||
stop_p = request.get('stop_price') or 0
|
||||
cur = market_data.get('current_price')
|
||||
cur_part = f' (현재가 {_money(cur)})' if cur else ''
|
||||
lines.append(f'{marker} 조건: {_md_bold(_money(stop_p) + " 도달 시")}{cur_part}')
|
||||
lines.append(f'{marker} 매도가격: {_md_bold(_money(request["price"]))} (지정가)')
|
||||
elif request['order_type'] in ('TRAILING_STOP', 'TRAILING_ARM'):
|
||||
# 승인 대상은 "계단 정의" — 각 계단의 조건단가는 고점이 오르면 제 하락률을 유지하며
|
||||
# 함께 따라 올라간다. 계단이 1개면 종전의 단일 트레일링과 같다.
|
||||
is_arm = request['order_type'] == 'TRAILING_ARM'
|
||||
cur = market_data.get('current_price')
|
||||
cur_part = f' (현재가 {_money(cur)})' if cur else ''
|
||||
# 초기 고점은 항상 등록 시점 현재가 — 과거 고점 기준 옵션은 2026-07-30 제거됨.
|
||||
# 예약 트레일링은 그 자리에 발동가가 들어간다(계단 계산 기준도 발동가).
|
||||
peak_v = request.get('trail_peak') or 0
|
||||
steps = request.get('trail_steps') or []
|
||||
if is_arm:
|
||||
gap = ''
|
||||
if cur:
|
||||
gap = f' — 발동까지 {_pct((peak_v - cur) / cur * 100)}'
|
||||
lines.append(f'{marker} 발동가: {_md_bold(_money(peak_v) + " 도달 시 시작")}{cur_part}{gap}')
|
||||
# 대기 예약이 당일 유효로 바뀐 뒤로는 상한가 초과 발동가가 '오늘 도달 불가' 확정이다.
|
||||
# 거부하지는 않는다(관리자님 지시) — 무의미하다는 사실만 승인 전에 알린다.
|
||||
up_lim = market_data.get('upper_limit') or 0
|
||||
if up_lim and peak_v > up_lim:
|
||||
lines.append(f'{cfg["warning_emoji"]} 오늘 상한가 {_money(up_lim)} 초과 — '
|
||||
f'오늘은 도달 불가, 장 마감에 만료됩니다')
|
||||
else:
|
||||
lines.append(f'{marker} 시작 고점: {_md_bold(_money(peak_v))} (등록 시점){cur_part}')
|
||||
min_sell = request.get('min_sell_price')
|
||||
if min_sell:
|
||||
# 최저 매도가가 손절선을 결정했는지(=트레일보다 높았는지) 명시 — 둘 중 어느 게
|
||||
# 지배했는지 안 보이면 관리자님이 값을 잘못 읽는다.
|
||||
tied = sum(1 for s in steps if s.get('floor_applied'))
|
||||
who = f'{tied}개 계단 적용' if tied else '미적용 (트레일이 더 높음)'
|
||||
lines.append(f'{marker} 최저 매도가: {_md_bold(_money(min_sell))} ({who})')
|
||||
lines.append('')
|
||||
steps_head = f'{marker} 매도 계단 {len(steps)}단'
|
||||
if is_arm:
|
||||
steps_head += ' (발동가 기준 예상)'
|
||||
lines.append(steps_head)
|
||||
for s in steps:
|
||||
cum = s.get('cum', 0)
|
||||
cum_part = '전량' if cum >= 100 else f'누적 {_num(cum)}%'
|
||||
lines.append(f' {s["n"]}단계 −{_num(s["pct"])}% '
|
||||
f'{_md_bold(_money(s["cond_uv"]) + " 도달")} → '
|
||||
f'{s["qty"]:,}주 ({cum_part})')
|
||||
rest = request['qty'] - sum(s['qty'] for s in steps)
|
||||
if rest > 0:
|
||||
lines.append(f' ↳ 나머지 {rest:,}주는 스톱 없음 (계속 보유)')
|
||||
lines.append('')
|
||||
lines.append(f'{marker} 고점이 오르면 계단이 통째로 따라 올라갑니다 (내려가지 않음)')
|
||||
if is_arm:
|
||||
# 이 기능의 유일한 위험(발동 시 PIN 재확인 없음)을 승인 화면에서 고지하는 자리다.
|
||||
# 실제 손절선이 미리보기 이상인 이유: 발동 시점 현재가 ≥ 발동가.
|
||||
lines.append(f'{marker} 지금은 주문이 나가지 않습니다 — '
|
||||
f'발동가에 닿는 순간 PIN 재확인 없이 위 계단이 자동 발주됩니다')
|
||||
lines.append(f'{marker} {_md_bold("오늘 장 마감까지만 유효")} — '
|
||||
f'미발동이면 자동 취소됩니다 (스톱주문과 같은 당일 유효)')
|
||||
lines.append(f'{marker} 실제 손절선은 발동 시점 현재가 기준이라 위 값 이상입니다')
|
||||
elif request['order_type'] == 'MARKET':
|
||||
lines.append(f'{marker} 가격: {_md_bold("시장가")}')
|
||||
else:
|
||||
@@ -215,6 +286,79 @@ def format_submitted(card_id: str, side: str, symbol_name: str, qty: int,
|
||||
return f'📨 [#{card_id}] {side_word} 접수: {symbol_name} {qty:,}주 @ {price_str}\n주문번호: {ord_no}'
|
||||
|
||||
|
||||
def format_trailing_submitted(info: dict) -> str:
|
||||
"""트레일링 계단 접수 결과 — 몇 단이 걸렸고 감시가 붙었는지를 한 화면에 담는다.
|
||||
|
||||
계단 일부만 접수될 수 있어(레그 독립 제출) 성공·실패를 나란히 적는다.
|
||||
실패분을 요약해 버리면 방어선이 몇 개인지 모르는 채로 남게 된다.
|
||||
"""
|
||||
placed = info.get('placed') or []
|
||||
failed = info.get('failed') or []
|
||||
acct = _account_display(info.get('account', ''))
|
||||
head = '🧪 [모의] 트레일링' if info.get('dry_run') else '✅ 트레일링'
|
||||
lines = [f'{head} {len(placed)}단 등록: {info["symbol_name"]}',
|
||||
f'[{acct}] 총 {info["total_qty"]:,}주 · 시작 고점 {_money(info["peak"])}']
|
||||
if info.get('min_sell_price'):
|
||||
lines.append(f'최저 매도가 {_money(info["min_sell_price"])}')
|
||||
for s in placed:
|
||||
cum = s.get('cum', 0)
|
||||
cum_part = '전량' if cum >= 100 else f'누적 {_num(cum)}%'
|
||||
no = f' · 주문 {s["ord_no"]}' if s.get('ord_no') else ''
|
||||
lines.append(f'{s["n"]}단계 −{_num(s["pct"])}% {_money(s["cond_uv"])} 도달 → '
|
||||
f'{s["qty"]:,}주 ({cum_part}){no}')
|
||||
rest = info['total_qty'] - sum(s['qty'] for s in placed)
|
||||
if rest > 0:
|
||||
lines.append(f'↳ 나머지 {rest:,}주는 스톱 없음 (계속 보유)')
|
||||
for s in failed:
|
||||
lines.append(f'⚠️ {s["n"]}단계 −{_num(s["pct"])}% {s["qty"]:,}주 접수 실패 '
|
||||
f'({s.get("reason")}) {s.get("error", "")}'.rstrip())
|
||||
if info.get('register_error'):
|
||||
lines.append('⚠️ 주문은 접수됐지만 트레일링 등록 실패 — 손절선이 따라 올라가지 않습니다. '
|
||||
f'({info["register_error"]})')
|
||||
elif info.get('res_id'):
|
||||
lines.append(f'감시 시작 ({info["res_id"]}) — 고점이 오르면 계단이 함께 올라갑니다')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def format_arm_registered(info: dict) -> str:
|
||||
"""예약 트레일링 등록 완료 — 아직 키움에 주문이 없다는 사실을 분명히 적는다."""
|
||||
acct = _account_display(info.get('account', ''))
|
||||
head = '🧪 [모의] 예약 트레일링' if info.get('dry_run') else '⏳ 예약 트레일링'
|
||||
steps = info.get('steps') or []
|
||||
lines = [f'{head} 등록: {info["symbol_name"]} ({info.get("symbol", "")})',
|
||||
f'[{acct}] {info["qty"]:,}주 · 발동가 {_money(info["trigger_price"])} 도달 시 시작']
|
||||
if info.get('cur_price'):
|
||||
cur = info['cur_price']
|
||||
gap = (info['trigger_price'] - cur) / cur * 100
|
||||
lines.append(f'현재가 {_money(cur)} · 발동까지 {_pct(gap)}')
|
||||
if info.get('min_sell_price'):
|
||||
lines.append(f'최저 매도가 {_money(info["min_sell_price"])}')
|
||||
step_parts = []
|
||||
for s in steps:
|
||||
cum = s.get('cum', 0)
|
||||
cum_part = '전량' if cum >= 100 else f'누적 {_num(cum)}%'
|
||||
step_parts.append(f'−{_num(s["pct"])}% {cum_part}')
|
||||
lines.append(f'매도 계단 {len(steps)}단: ' + ' · '.join(step_parts))
|
||||
if info.get('arm_id'):
|
||||
lines.append(f'대기 시작 ({info["arm_id"]}) — **오늘 장 마감까지** 유효')
|
||||
lines.append('지금은 키움에 주문이 없습니다. 발동가에 닿으면 PIN 재확인 없이 자동 발주됩니다.')
|
||||
lines.append('미발동으로 장이 끝나면 자동 취소됩니다 (스톱주문과 같은 당일 유효).')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def format_arm_fired(info: dict) -> str:
|
||||
"""예약 트레일링 발동 — 계단 접수 결과는 format_trailing_submitted 와 같은 모양이라 재사용하고,
|
||||
발동 경위(발동가·실제 발동가·수량 축소)만 앞뒤에 덧붙인다."""
|
||||
head = [f'🎯 예약 트레일링 발동 — {info["symbol_name"]} ({info.get("symbol", "")})',
|
||||
f'발동가 {_money(info["trigger_price"])} 도달 (현재가 {_money(info["peak"])})']
|
||||
if info.get('qty_reduced'):
|
||||
head.append(f'⚠️ 매도가능 {info["total_qty"]:,}주 — '
|
||||
f'예약 {info["arm_qty"]:,}주에서 줄여 재배분했습니다')
|
||||
body = format_trailing_submitted(info)
|
||||
tail = ['※ 스톱주문은 장 마감 시 소멸합니다 (자동 재등록 안 함)']
|
||||
return '\n'.join(head + ['', body] + tail)
|
||||
|
||||
|
||||
def format_expired(card_id: str, side: str) -> str:
|
||||
side_word = '매수' if side == 'BUY' else '매도'
|
||||
return f'⏱️ [#{card_id}] 승인 만료. {side_word}가 취소되었습니다.'
|
||||
@@ -292,6 +436,10 @@ def format_card_locked(active: Optional[dict] = None) -> str:
|
||||
price_str = '시장가'
|
||||
elif order_type == 'AGGRESSIVE_LIMIT':
|
||||
price_str = f'공격적 지정가 {_money(price)}' if price else '공격적 지정가'
|
||||
elif order_type == 'TRAILING_STOP':
|
||||
steps = active.get('trail_steps') or []
|
||||
pcts = '/'.join(f'−{_num(s["pct"])}%' for s in steps)
|
||||
price_str = f'트레일링 {len(steps)}단 {pcts} (1단계 손절 {_money(active.get("stop_price"))})'
|
||||
else:
|
||||
price_str = _money(price) if price is not None else '지정가'
|
||||
|
||||
|
||||
@@ -130,6 +130,15 @@ def _safe_broker_executions(account_label: str, now: datetime) -> tuple[Optional
|
||||
return executions, None
|
||||
|
||||
|
||||
def nxt_eligible(symbol: str) -> bool:
|
||||
"""NXT 거래가능 여부 (종목코드만으로). 캐시 읽기라 API 콜 0.
|
||||
|
||||
collect_market_data 를 거치지 않는 호출자용 — 트레일링 감시 루프가 대기 예약을
|
||||
발동시킬 때 라우팅(_AL/_KRX)을 정하는 데 쓴다.
|
||||
"""
|
||||
return _nxt_eligible(symbol, {})
|
||||
|
||||
|
||||
def collect_market_data(account_label: str, symbol: str, side: str, qty: int) -> dict:
|
||||
now = datetime.now(KST)
|
||||
quote = _safe_quote(symbol, account_label)
|
||||
@@ -156,21 +165,37 @@ def collect_market_data(account_label: str, symbol: str, side: str, qty: int) ->
|
||||
'stock_meta': stock_meta,
|
||||
}
|
||||
|
||||
try:
|
||||
open_orders = kc.get_open_orders(account_label)
|
||||
except Exception:
|
||||
open_orders = []
|
||||
|
||||
if side == 'BUY':
|
||||
try:
|
||||
bal = kc.get_balance(account_label)
|
||||
md['balance_d2'] = _to_int(bal.get('d2_entra'))
|
||||
d2 = _to_int(bal.get('d2_entra'))
|
||||
# d2_entra는 미체결 매수 지정가로 묶인 예수금을 아직 포함 → 실제 가용액에서 차감.
|
||||
locked = sum(_to_int(o.get('order_price')) * _to_int(o.get('unfilled_qty'))
|
||||
for o in open_orders
|
||||
if o.get('side') == 'BUY' and _to_int(o.get('order_price')) > 0)
|
||||
md['balance_d2'] = max(0, d2 - locked)
|
||||
except Exception:
|
||||
md['balance_d2'] = 0
|
||||
else:
|
||||
try:
|
||||
positions = kc.get_positions(account_label)
|
||||
md['position_qty'] = next(
|
||||
(_to_int(p.get('trde_able_qty') or p.get('qty') or p.get('hold_qty') or p.get('rmnd_qty'))
|
||||
for p in positions
|
||||
pos = next((p for p in positions
|
||||
if (p.get('code') == symbol) or (p.get('symbol') == symbol) or (p.get('stk_cd') == symbol)),
|
||||
0,
|
||||
)
|
||||
None)
|
||||
if pos:
|
||||
hold = _to_int(pos.get('qty') or pos.get('hold_qty') or pos.get('rmnd_qty'))
|
||||
trde_able = _to_int(pos.get('trde_able_qty') or hold)
|
||||
pend_sell = sum(_to_int(o.get('unfilled_qty')) for o in open_orders
|
||||
if o.get('side') == 'SELL' and o.get('code') == symbol)
|
||||
# trde_able이 미체결 매도 제외 여부 불확실 → min으로 이중차감 방지 (check 핸들러와 동일).
|
||||
md['position_qty'] = max(0, min(trde_able, hold - pend_sell))
|
||||
else:
|
||||
md['position_qty'] = 0
|
||||
except Exception:
|
||||
md['position_qty'] = 0
|
||||
|
||||
|
||||
@@ -76,6 +76,18 @@ def _parse_hms(s: str) -> dtime:
|
||||
|
||||
|
||||
def session_at(now: datetime) -> str:
|
||||
"""현재 시각의 거래 세션. 경계 시각은 limits.json['trading_hours'] 가 단일 진실 소스다.
|
||||
|
||||
NXT_PRE(08:00~09:00) / KRX_NXT(09:00:30~15:20) / KRX_CLOSE(15:20~15:30)
|
||||
NXT_AFTER(15:30~16:00) / AFTER(16:00~20:00) / CLOSED
|
||||
|
||||
⚠️ AFTER = **KRX 애프터마켓 + NXT 애프터마켓 동시**(2026-09-14 시행). 이걸 NXT_AFTER 로
|
||||
뭉쳐 두면 NXT 미상장 종목이 validate_trading_hours 의 NXT_NOT_ELIGIBLE 로 막혀
|
||||
**애프터마켓에 주문을 낼 수 없다** — 정작 KRX 애프터마켓에서는 거래되는 종목들이다
|
||||
(2026-09-21 실측: 삼성전자우 KRX 거래량 16:05→18:00 +134,198주).
|
||||
⚠️ 15:30~16:00 은 NXT_AFTER 로 남긴다 — KRX 쪽은 시간외 종가매매(15:40~16:00)뿐이고
|
||||
우리는 그 주문유형(trde_tp=81)을 쓰지 않으므로 "NXT 만 거래 가능"이 맞다.
|
||||
"""
|
||||
th = _limits()['trading_hours']
|
||||
t = now.replace(tzinfo=None).time()
|
||||
if _parse_hms(th['nxt_pre_start']) <= t < _parse_hms(th['nxt_pre_end']):
|
||||
@@ -84,11 +96,26 @@ def session_at(now: datetime) -> str:
|
||||
return 'KRX_NXT'
|
||||
if _parse_hms(th['krx_closing_auction_start']) <= t < _parse_hms(th['krx_closing_auction_end']):
|
||||
return 'KRX_CLOSE'
|
||||
# ⚠️ AFTER 를 NXT_AFTER 보다 **먼저** 본다 — nxt_after(15:30~20:00) 구간이 애프터마켓을
|
||||
# 통째로 덮고 있어서, 순서를 바꾸면 16:00 이후가 영원히 NXT_AFTER 로 잡힌다.
|
||||
if _parse_hms(th['krx_after_start']) <= t < _parse_hms(th['nxt_after_end']):
|
||||
return 'AFTER'
|
||||
if _parse_hms(th['nxt_after_start']) <= t < _parse_hms(th['nxt_after_end']):
|
||||
return 'NXT_AFTER'
|
||||
return 'CLOSED'
|
||||
|
||||
|
||||
def after_trading_day(now: datetime) -> bool:
|
||||
"""그날 거래 세션이 전부 끝났는가 (NXT 애프터 마감 = 20:00 이후).
|
||||
|
||||
⚠️ `session_at(now) == 'CLOSED'` 로 대신하면 안 된다 — 그 판정은 **장 시작 전 새벽**과
|
||||
**09:00:00~09:00:30 틈**(NXT 프리 종료와 정규장 시작 사이)에도 CLOSED 를 돌려주므로,
|
||||
당일 유효기간을 재는 데 쓰면 아침에 멀쩡한 예약이 만료로 잡힌다.
|
||||
"""
|
||||
th = _limits()['trading_hours']
|
||||
return now.replace(tzinfo=None).time() >= _parse_hms(th['nxt_after_end'])
|
||||
|
||||
|
||||
def is_today_holiday(now: datetime) -> bool:
|
||||
th = _limits()['trading_hours']
|
||||
rel = th.get('holiday_state_file')
|
||||
@@ -103,6 +130,11 @@ def is_today_holiday(now: datetime) -> bool:
|
||||
return False
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
holidays = data.get('holidays') if isinstance(data, dict) else data
|
||||
# holiday_sync.py 가 쓰는 실제 형태는 {날짜: 이름} dict 다. list 만 받던 시절의
|
||||
# 코드가 남아 있어 dict 를 만나면 조용히 False 를 반환했고(= 휴장일 가드가 통째로
|
||||
# 무력화), 2026-08-25 예약 트레일링 작업에서 발견해 dict 를 먼저 처리하게 했다.
|
||||
if isinstance(holidays, dict):
|
||||
return today in holidays
|
||||
if not isinstance(holidays, list):
|
||||
return False
|
||||
for h in holidays:
|
||||
@@ -121,10 +153,31 @@ def validate_trading_hours(now: datetime, is_holiday: bool, nxt_eligible: bool)
|
||||
return Result.REJECT('OUTSIDE_HOURS', f'거래시간 외 ({now.strftime("%H:%M:%S")})')
|
||||
if sess in ('NXT_PRE', 'NXT_AFTER') and not nxt_eligible:
|
||||
return Result.REJECT('NXT_NOT_ELIGIBLE', '지금은 NXT 시간대인데 이 종목은 NXT 거래 불가')
|
||||
# ⚠️ AFTER(16:00~20:00)는 nxt_eligible 을 보지 않는다 — KRX 애프터마켓이 NXT 미상장
|
||||
# 종목을 받으므로, 여기서 막으면 정작 거래되는 종목을 거부한다.
|
||||
# ⚠️ 애프터마켓 제외종목(ETF/ETN + 거래정지·투자경고·이상급등 등 9종)의 **서버 가드는
|
||||
# 없다** — ETF/ETN 은 자산웹 버튼이 stock_codes 의 etf_etn 으로 막고, 나머지는 판별
|
||||
# 필드가 없어 키움 거부 메시지가 최종 방어선이다. market_data 에 플래그를 실어
|
||||
# 여기서 막으려면 datasource 를 함께 고쳐야 한다(미구현).
|
||||
return Result.OK(code=sess)
|
||||
|
||||
|
||||
def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str]) -> str:
|
||||
# 스톱지정가(trde_tp=28)를 쓰는 주문유형. SOR 라우팅으로는 발주되지 않는다.
|
||||
STOP_ORDER_TYPES = ('STOP_LIMIT', 'TRAILING_STOP', 'TRAILING_ARM')
|
||||
|
||||
|
||||
def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str],
|
||||
order_type: Optional[str] = None) -> str:
|
||||
"""세션·NXT 거래여부로 거래소 라우팅 suffix 를 정한다.
|
||||
|
||||
⚠️ 스톱 계열은 SOR(_AL) 로 낼 수 없다 — 키움이 `407022: SOR주문이 불가능한
|
||||
주문종류입니다` 로 거부한다(2026-09-21 삼성전자 실측). NXT 거래 종목은 정규장
|
||||
라우팅이 _AL 이라, 이 강등이 없으면 대형주 트레일링이 **항상** 거부된다.
|
||||
(2026-07-30 제닉스로보틱스가 성공했던 건 NXT 미거래 종목이라 KRX 로 나갔기 때문)
|
||||
|
||||
NXT 단독 시간대(_NX)가 스톱을 받는지는 실증 안 됐다 — 시도는 그대로 두고,
|
||||
거부되면 그 메시지로 드러나게 한다(KRX 가 닫힌 시간이라 강등할 곳도 없다).
|
||||
"""
|
||||
routing = _limits()['routing']
|
||||
suffix_map = {
|
||||
'AL': routing['suffix_AL'],
|
||||
@@ -134,16 +187,31 @@ def determine_routing(now: datetime, nxt_eligible: bool, force: Optional[str]) -
|
||||
if force:
|
||||
if force not in routing['force_options']:
|
||||
raise ValueError(f'unknown routing force: {force}')
|
||||
return suffix_map[force]
|
||||
suffix = suffix_map[force]
|
||||
else:
|
||||
sess = session_at(now)
|
||||
if sess in ('NXT_PRE', 'NXT_AFTER'):
|
||||
return routing['suffix_NX']
|
||||
if sess == 'KRX_CLOSE':
|
||||
return routing['suffix_KRX']
|
||||
if sess == 'KRX_NXT':
|
||||
return routing['suffix_AL'] if nxt_eligible else routing['suffix_KRX']
|
||||
suffix = routing['suffix_NX']
|
||||
elif sess == 'KRX_CLOSE':
|
||||
suffix = routing['suffix_KRX']
|
||||
elif sess == 'AFTER':
|
||||
# 애프터마켓은 **KRX 로 보낸다** — KRX 애프터마켓이 대상 종목을 전부 받고
|
||||
# (2026-09-21 실측: NXT 미상장 종목도 16:00 이후 KRX 거래량이 증가),
|
||||
# dmst_stex_tp 에 애프터 전용 값이 생기지도 않았다(KRX/NXT/SOR 그대로).
|
||||
# ⚠️ SOR 을 쓰지 않는 이유 — 시세는 SOR 이 KRX+NXT 통합으로 오지만
|
||||
# **주문이 애프터마켓에 어떻게 라우팅되는지는 미검증**이다. 오늘 아침
|
||||
# "SOR 주문이 불가능한 주문종류입니다"(407022) 가 정확히 그 함정이었다.
|
||||
# 검증된 경로만 쓴다. NXT 상장 종목도 KRX 애프터마켓에서 거래되므로 손실 없다.
|
||||
suffix = routing['suffix_KRX']
|
||||
elif sess == 'KRX_NXT':
|
||||
suffix = routing['suffix_AL'] if nxt_eligible else routing['suffix_KRX']
|
||||
else:
|
||||
raise ValueError('CLOSED session has no valid routing')
|
||||
|
||||
if order_type in STOP_ORDER_TYPES and suffix == routing['suffix_AL']:
|
||||
return routing['suffix_KRX']
|
||||
return suffix
|
||||
|
||||
|
||||
# ---- 가격 가드 (±30% 상한가/하한가) ----
|
||||
|
||||
@@ -180,7 +248,8 @@ def validate_market_order_session(now: datetime, order_type: str) -> Result:
|
||||
if order_type != 'MARKET':
|
||||
return Result.OK()
|
||||
sess = session_at(now)
|
||||
if sess in ('NXT_PRE', 'NXT_AFTER'):
|
||||
# 애프터마켓 제출가능 호가는 지정가·최우선지정가·최유리지정가(+IOC/FOK)뿐 — **시장가 없음**.
|
||||
if sess in ('NXT_PRE', 'NXT_AFTER', 'AFTER'):
|
||||
return Result.REJECT('MARKET_NOT_ALLOWED_IN_NXT',
|
||||
'NXT 시간대에는 시장가 불가 — 지정가만 가능. "지금 바로" 또는 가격 명시로 다시 시도.')
|
||||
if sess == 'KRX_CLOSE':
|
||||
@@ -503,7 +572,8 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
return r
|
||||
if request['side'] not in ('BUY', 'SELL'):
|
||||
return Result.REJECT('INVALID_SIDE', f'잘못된 방향: {request["side"]}')
|
||||
if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
if request['order_type'] not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT',
|
||||
'TRAILING_STOP', 'TRAILING_ARM'):
|
||||
return Result.REJECT('INVALID_ORDER_TYPE', f'잘못된 주문방식: {request["order_type"]}')
|
||||
r = validate_trading_hours(market_data['now'], market_data.get('is_holiday', False),
|
||||
market_data.get('nxt_eligible', False))
|
||||
@@ -525,6 +595,42 @@ def validate_request(request: dict, market_data: dict) -> Result:
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
if request['order_type'] in ('STOP_LIMIT', 'TRAILING_STOP'):
|
||||
# 스톱지정가: 지정가(체결가)·조건단가(트리거) 둘 다 가격제한 이내 + 매도 전용.
|
||||
# 보호매도(하락 시 매도)라 트리거는 현재가보다 낮아야 함 — 아니면 즉시 발동돼 스톱 의미 없음.
|
||||
# 트레일링도 키움엔 스톱지정가로 나가므로 같은 검증을 받는다 (조건단가는 자동 계산값).
|
||||
if request['side'] != 'SELL':
|
||||
return Result.REJECT('STOP_SELL_ONLY', '스톱지정가는 현재 매도만 지원')
|
||||
stop_price = request.get('stop_price')
|
||||
if not stop_price or stop_price <= 0:
|
||||
return Result.REJECT('STOP_PRICE_REQUIRED', '조건단가(트리거 가격)를 입력하세요')
|
||||
r = validate_price_band(request['side'], request['price'],
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
r = validate_price_band(request['side'], stop_price,
|
||||
market_data['upper_limit'], market_data['lower_limit'])
|
||||
if not r.ok:
|
||||
return r
|
||||
cur = market_data.get('current_price') or 0
|
||||
if cur and stop_price >= cur:
|
||||
return Result.REJECT('STOP_TRIGGER_NOT_BELOW',
|
||||
f'조건단가 {stop_price:,}원 ≥ 현재가 {cur:,}원 — '
|
||||
f'하락 시 매도(스톱)는 현재가보다 낮게 설정해야 함')
|
||||
if request['order_type'] == 'TRAILING_ARM':
|
||||
# 예약 트레일링은 지금 나가는 주문이 아니라 "발동가에 닿으면 시작" 하는 예약이다.
|
||||
# 그래서 스톱지정가와 두 가지 검증을 일부러 건너뛴다:
|
||||
# · 가격제한폭 — 발동가는 오늘 상한가 위여도 통과시킨다. 실제 발주는 발동 시점의
|
||||
# 현재가로 계산되므로 그때 검증된다. arm 이 당일 유효가 된 뒤로는 상한가 초과가
|
||||
# "오늘 도달 불가 = 그냥 만료" 를 뜻하지만, **막지 않고 경고만** 한다
|
||||
# (2026-08-25 관리자님 결정). 경고는 승인 카드(card.format_card)와 웹
|
||||
# 미리보기·확인 팝업 3곳에 있다 — 여기서 거부로 바꾸지 말 것.
|
||||
# · 조건단가 < 현재가 — 발동가는 정의상 현재가보다 위다. 이 검사를 태우면 전부 거부된다.
|
||||
# 아래 매도 보유수량 검증은 그대로 받는다 — 지금 없는 종목을 예약해봐야 무의미하다.
|
||||
if request['side'] != 'SELL':
|
||||
return Result.REJECT('ARM_SELL_ONLY', '예약 트레일링은 매도 전용')
|
||||
if not request.get('trigger_price'):
|
||||
return Result.REJECT('ARM_TRIGGER', '발동가를 입력하세요')
|
||||
if request['side'] == 'BUY':
|
||||
basis = None
|
||||
if request['order_type'] == 'MARKET':
|
||||
|
||||
@@ -35,7 +35,7 @@ if str(_PARENT) not in sys.path:
|
||||
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
|
||||
from . import card, datasource, fill_watcher, guards, kiwoom_order, ledger, pin, sidecar # noqa: E402
|
||||
from . import card, datasource, fill_watcher, guards, kiwoom_order, ledger, pin, sidecar, trailing # noqa: E402
|
||||
|
||||
LIMITS_FILE = Path(__file__).resolve().parent / 'limits.json'
|
||||
OPENCLAW_CONFIG = Path.home() / '.openclaw' / 'openclaw.json'
|
||||
@@ -164,16 +164,79 @@ def propose_trade(
|
||||
price: Optional[int] = None,
|
||||
routing_force: Optional[str] = None,
|
||||
budget: Optional[int] = None,
|
||||
stop_price: Optional[int] = None,
|
||||
trail_steps: Optional[list] = None,
|
||||
min_sell_price: Optional[int] = None,
|
||||
trigger_price: Optional[int] = None,
|
||||
) -> dict:
|
||||
sidecar.guard_or_raise()
|
||||
_sweep_expired_and_notify()
|
||||
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT',
|
||||
'TRAILING_STOP', 'TRAILING_ARM'):
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('INVALID_ORDER_TYPE', f'잘못된 주문방식: {order_type}')}
|
||||
if side not in ('BUY', 'SELL'):
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('INVALID_SIDE', f'잘못된 방향: {side}')}
|
||||
if order_type == 'STOP_LIMIT':
|
||||
if side != 'SELL':
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('STOP_SELL_ONLY', '스톱지정가는 현재 매도만 지원')}
|
||||
if not price or price <= 0 or not stop_price or stop_price <= 0:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('STOP_INPUT',
|
||||
'스톱지정가는 지정가와 조건단가(트리거)가 모두 필요')}
|
||||
norm_steps = None
|
||||
if order_type in ('TRAILING_STOP', 'TRAILING_ARM'):
|
||||
# 트레일링은 계단 정의(하락률·누적 비중)만 입력받고, 계단별 조건단가·지정가·주수는
|
||||
# 현재가와 보유수량 기준으로 자동 계산한다 (md 수집 후).
|
||||
#
|
||||
# 예약 트레일링(TRAILING_ARM)은 같은 입력에 발동가가 하나 더 붙고, 계단 계산의 기준을
|
||||
# 현재가 대신 발동가로 쓴다. 그 외 검증은 전부 공유한다 — 발동하면 결국 같은 물건이
|
||||
# 되므로 여기서 갈라놓으면 한쪽만 고쳐지는 사고가 난다.
|
||||
is_arm = order_type == 'TRAILING_ARM'
|
||||
kind = '예약 트레일링' if is_arm else '트레일링 스톱'
|
||||
if side != 'SELL':
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'ARM_SELL_ONLY' if is_arm else 'STOP_SELL_ONLY', f'{kind}은 매도 전용')}
|
||||
try:
|
||||
norm_steps = trailing.normalize_steps(trail_steps or [])
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('TRAIL_STEPS', f'계단 정의 오류: {e}')}
|
||||
if budget is not None:
|
||||
# 계단별 주수는 보유수량을 나눠 만든다 — 예산 환산이 끼면 기준이 두 개가 된다.
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('TRAIL_BUDGET',
|
||||
f'{kind}은 수량으로만 지정 (예산 환산 불가)')}
|
||||
if min_sell_price is not None and min_sell_price <= 0:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('TRAIL_MIN_PRICE',
|
||||
'최저 매도가는 0보다 커야 함 (제한 없으면 비우세요)')}
|
||||
if is_arm and (not trigger_price or trigger_price <= 0):
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('ARM_TRIGGER', '발동가를 입력하세요')}
|
||||
# 같은 계좌·종목에 활성 트레일링과 대기 예약이 함께 있으면 안 된다 — 어느 쪽이든 먼저 취소.
|
||||
dup = trailing.find_by_symbol(account, symbol)
|
||||
if dup:
|
||||
dup_steps = dup.get('steps') or []
|
||||
top_uv = max((s['cond_uv'] for s in dup_steps), default=0)
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'TRAIL_DUPLICATE',
|
||||
f'{symbol_name or symbol} 트레일링 예약이 이미 있음 '
|
||||
f'({dup["id"]} · {len(dup_steps)}단 · 1단계 손절 {top_uv:,}원) '
|
||||
f'— 먼저 취소하세요')}
|
||||
dup_arm = trailing.find_arm_by_symbol(account, symbol)
|
||||
if dup_arm:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'TRAIL_DUPLICATE',
|
||||
f'{symbol_name or symbol} 예약 트레일링이 이미 있음 '
|
||||
f'({dup_arm["id"]} · 발동가 {dup_arm["trigger_price"]:,}원 · '
|
||||
f'{len(dup_arm.get("steps") or [])}단) — 먼저 취소하세요')}
|
||||
if budget is not None and qty is not None:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('AMBIGUOUS_INPUT',
|
||||
@@ -186,6 +249,75 @@ def propose_trade(
|
||||
# collect_market_data 의 잔고/보유 조회는 qty 와 무관.
|
||||
md = datasource.collect_market_data(account, symbol, side, qty or 0)
|
||||
|
||||
trail_final = None
|
||||
trail_peak = None
|
||||
if order_type in ('TRAILING_STOP', 'TRAILING_ARM'):
|
||||
is_arm = order_type == 'TRAILING_ARM'
|
||||
# 현재가를 못 받으면 손절선을 정할 수 없어 진행 불가 (호가 fallback 금지 — 기준이 흔들림).
|
||||
cur = md.get('current_price') or 0
|
||||
if cur <= 0:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'TRAIL_NO_PRICE',
|
||||
'현재가를 못 받아 발동가 도달 여부를 판정할 수 없음' if is_arm
|
||||
else '현재가를 못 받아 트레일링 손절선을 정할 수 없음')}
|
||||
if is_arm:
|
||||
# 이미 닿아 있으면 '기다렸다가'라는 이 주문유형의 존재 이유가 사라진다 —
|
||||
# 등록하는 순간 다음 감시(1분 내)에 바로 발동한다. 그럴 거면 일반 트레일링이 맞다.
|
||||
if cur >= trigger_price:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'ARM_ALREADY_REACHED',
|
||||
f'현재가 {cur:,}원이 이미 발동가 {trigger_price:,}원 이상입니다 — '
|
||||
f'발동가를 올리시거나 일반 트레일링 스톱으로 등록하세요')}
|
||||
# 계단 계산 기준 = 발동가. 실제 발주는 발동 시점 현재가로 다시 계산하지만,
|
||||
# 여기서 미리 재보는 이유는 ①카드에 예상 손절선·주수를 보이고 ②발동해도 못 쓰는
|
||||
# 조합(수량 부족·즉시발동)을 지금 걸러내기 위해서다.
|
||||
# 발동 시점엔 현재가 >= 발동가 이므로 실제 손절선은 이 미리보기 이상이다
|
||||
# — 관리자님이 승인한 것보다 불리해지는 방향으로는 움직이지 않는다.
|
||||
basis = trigger_price
|
||||
else:
|
||||
# 초기 고점은 등록 시점 현재가로 고정. 과거 고점(52주·매수후)을 쓰면 손절선이 현재가
|
||||
# 위로 올라가 등록 즉시 발동한다 — 손실 종목은 아예 등록 불가, 이익 종목은 현재가와
|
||||
# 결과가 같아 옵션 자체를 제거했다(2026-07-30 실측 근거로 관리자님 결정).
|
||||
basis = cur
|
||||
trail_peak = basis
|
||||
try:
|
||||
leveled = trailing.compute_step_levels(trail_peak, norm_steps, min_sell_price)
|
||||
except ValueError as e:
|
||||
return {'ok': False, 'message': card.format_rejected('TRAIL_LEVEL', str(e))}
|
||||
# 계단별 주수 배분. 수량이 적어 0주가 된 계단은 발주할 수 없으니 떨어낸다 —
|
||||
# 단계 번호(n)는 그대로 두어 "몇 단계가 빠졌는지" 가 카드에서 보이게 한다.
|
||||
qtys = trailing.allocate_step_qty(qty, [s['weight'] for s in norm_steps])
|
||||
trail_final = [dict(s, qty=q) for s, q in zip(leveled, qtys) if q > 0]
|
||||
if not trail_final:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'TRAIL_QTY', f'{qty:,}주로는 계단을 나눌 수 없음 — 수량을 늘리거나 계단을 줄이세요')}
|
||||
# 가장 얕은 계단이 제일 먼저 발동한다. 이게 현재가 이상이면 등록하는 순간 터진다.
|
||||
# 고점=현재가로 고정된 뒤로는 최저 매도가가 현재가보다 높은 경우만 남는다
|
||||
# (트레일 폭은 항상 현재가 아래로 내려감).
|
||||
top = trail_final[0]
|
||||
if top['cond_uv'] >= basis:
|
||||
ref = '발동가' if is_arm else '현재가'
|
||||
when = '발동하자마자' if is_arm else '등록 즉시'
|
||||
if top['floor_applied']:
|
||||
msg = (f'최저 매도가 {min_sell_price:,}원이 {ref} {basis:,}원보다 높아 '
|
||||
f'{when} 발동됩니다 — {ref}보다 낮게 설정하세요')
|
||||
else:
|
||||
msg = (f'1단계 손절선 {top["cond_uv"]:,}원이 {ref} {basis:,}원 이상이라 '
|
||||
f'{when} 발동됩니다')
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
'ARM_IMMEDIATE' if is_arm else 'TRAIL_IMMEDIATE', msg)}
|
||||
if is_arm:
|
||||
# 예약 단계에선 나가는 주문이 없다 — 발주가는 발동 시점에 정해진다.
|
||||
price = None
|
||||
stop_price = None
|
||||
else:
|
||||
price = top['ord_uv']
|
||||
stop_price = top['cond_uv']
|
||||
|
||||
budget_conversion = None
|
||||
if budget is not None:
|
||||
conv = guards.convert_budget_to_qty(side, budget, md.get('orderbook'),
|
||||
@@ -211,6 +343,20 @@ def propose_trade(
|
||||
}
|
||||
if order_type == 'LIMIT':
|
||||
request['price'] = price
|
||||
elif order_type in ('STOP_LIMIT', 'TRAILING_STOP'):
|
||||
request['price'] = price
|
||||
request['stop_price'] = stop_price
|
||||
if order_type == 'TRAILING_STOP':
|
||||
request['trail_steps'] = trail_final
|
||||
request['min_sell_price'] = min_sell_price
|
||||
request['trail_peak'] = trail_peak
|
||||
elif order_type == 'TRAILING_ARM':
|
||||
# price/stop_price 를 넣지 않는다 — 지금 나가는 주문이 없고, 넣으면 validate_request 의
|
||||
# 가격제한폭·"조건단가 < 현재가" 검사에 걸린다(둘 다 예약엔 무의미하다).
|
||||
request['trigger_price'] = trigger_price
|
||||
request['trail_steps'] = trail_final
|
||||
request['min_sell_price'] = min_sell_price
|
||||
request['trail_peak'] = trail_peak
|
||||
|
||||
r = guards.validate_request(request, md)
|
||||
if not r.ok:
|
||||
@@ -230,7 +376,7 @@ def propose_trade(
|
||||
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
|
||||
state_warning = state_eval['warning'] # 카드에 표시할 경고 (None이면 정상)
|
||||
|
||||
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)
|
||||
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force, order_type)
|
||||
|
||||
estimate = None
|
||||
final_price = price
|
||||
@@ -254,6 +400,24 @@ def propose_trade(
|
||||
'account': account, 'side': side, 'symbol': symbol, 'symbol_name': symbol_name,
|
||||
'qty': qty, 'price': final_price, 'order_type': order_type, 'routing_suffix': suffix,
|
||||
}
|
||||
if order_type == 'STOP_LIMIT':
|
||||
payload['stop_price'] = stop_price
|
||||
elif order_type == 'TRAILING_STOP':
|
||||
# 승인 시점에 확정되는 것: 계좌·종목·계단별 수량·하락률·최저 매도가.
|
||||
# 이후 감시 루프는 계단별 조건단가만 올린다 (수량·계단 구성은 불변).
|
||||
payload['stop_price'] = stop_price
|
||||
payload['trail_steps'] = trail_final
|
||||
payload['trail_peak'] = trail_peak
|
||||
payload['min_sell_price'] = min_sell_price
|
||||
elif order_type == 'TRAILING_ARM':
|
||||
# 승인 시점에 확정: 계좌·종목·수량·발동가·계단 정의·최저 매도가.
|
||||
# 발동 시점에 정해짐: 고점(그때의 현재가)·계단별 조건단가/지정가/주수·라우팅.
|
||||
# ⚠️ trail_steps 에는 레벨 없는 정규화 계단만 넣는다 — 발동 경로가 미리보기 가격을
|
||||
# 실수로 재사용하지 못하게 구조적으로 막는다. 미리보기는 별도 키(trail_preview).
|
||||
payload['trigger_price'] = trigger_price
|
||||
payload['trail_steps'] = norm_steps
|
||||
payload['trail_preview'] = trail_final
|
||||
payload['min_sell_price'] = min_sell_price
|
||||
|
||||
try:
|
||||
pending = _pin_store.issue(account, payload)
|
||||
@@ -438,6 +602,10 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
|
||||
if p.get('multi'):
|
||||
return _submit_multi_legs(card_obj, dry_run)
|
||||
if p['order_type'] == 'TRAILING_STOP':
|
||||
return _submit_trailing_steps(card_obj, dry_run)
|
||||
if p['order_type'] == 'TRAILING_ARM':
|
||||
return _register_trailing_arm(card_obj, dry_run)
|
||||
|
||||
submit_order_type = 'LIMIT' if p['order_type'] == 'AGGRESSIVE_LIMIT' else p['order_type']
|
||||
res = kiwoom_order.submit(
|
||||
@@ -450,6 +618,7 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
routing_suffix=p['routing_suffix'],
|
||||
dry_run=dry_run,
|
||||
card_id=card_obj.card_id,
|
||||
stop_price=p.get('stop_price'),
|
||||
)
|
||||
|
||||
if res['ok']:
|
||||
@@ -460,9 +629,9 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
msg = card.format_submitted(card_obj.card_id, p['side'],
|
||||
p.get('symbol_name', p['symbol']),
|
||||
p['qty'], p.get('price'), ord_no)
|
||||
if ord_no:
|
||||
# 키움에서 ord_no 받으면 백그라운드 체결 추적 시작 (kt00007 폴링).
|
||||
# 부분체결·완전체결·사후거절·30분 미체결 시 텔레그램 자동 알림.
|
||||
if ord_no:
|
||||
fill_watcher.watch(
|
||||
ord_no=ord_no, account=p['account'], side=p['side'],
|
||||
symbol=p['symbol'], symbol_name=p.get('symbol_name', p['symbol']),
|
||||
@@ -475,6 +644,167 @@ def submit_with_pin(pin_input: str, dry_run: bool = True) -> dict:
|
||||
'detail': res}
|
||||
|
||||
|
||||
def _register_trailing_arm(card_obj, dry_run: bool) -> dict:
|
||||
"""예약 트레일링 카드 — 키움 호출 0. 발동가 대기 예약 1건을 파일에 쓰고 끝난다.
|
||||
|
||||
PIN 은 여기서 소비된다. 발동 시점엔 PIN 이 없다(관리자님 명시 승인) — 그래서 승인 대상에
|
||||
발동가까지 포함되고, 감시 루프는 그 확정된 내용을 조건 도달 시 집행만 한다.
|
||||
"""
|
||||
p = card_obj.payload
|
||||
name = p.get('symbol_name', p['symbol'])
|
||||
info = {'card_id': card_obj.card_id, 'account': p['account'], 'symbol': p['symbol'],
|
||||
'symbol_name': name, 'qty': p['qty'], 'trigger_price': p['trigger_price'],
|
||||
'min_sell_price': p.get('min_sell_price'), 'steps': p.get('trail_steps') or [],
|
||||
'dry_run': dry_run, 'arm_id': None}
|
||||
if dry_run:
|
||||
return {'ok': True, 'message': card.format_arm_registered(info)}
|
||||
try:
|
||||
arm = trailing.register_arm(
|
||||
account=p['account'], symbol=p['symbol'], symbol_name=name,
|
||||
qty=p['qty'], trigger_price=p['trigger_price'],
|
||||
steps=p.get('trail_steps') or [],
|
||||
min_sell_price=p.get('min_sell_price'), card_id=card_obj.card_id)
|
||||
except Exception as e:
|
||||
# 주문이 나간 게 없으므로 여기선 그냥 거부로 끝내면 된다 (트레일링 등록 실패와 달리
|
||||
# 되돌릴 외부 효과가 없다).
|
||||
ledger.append('arm_fire_failed', {'card_id': card_obj.card_id,
|
||||
'error': repr(e), 'stage': 'register', **p})
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected('ARM_REGISTER_FAILED',
|
||||
f'{name} 예약 등록 실패 — {e}')}
|
||||
info['arm_id'] = arm['id']
|
||||
ledger.append('arm_registered', {'card_id': card_obj.card_id, 'arm_id': arm['id'], **p})
|
||||
return {'ok': True, 'message': card.format_arm_registered(info), 'arm_id': arm['id']}
|
||||
|
||||
|
||||
def cancel_trailing_arm(arm_id: str) -> dict:
|
||||
"""대기 예약 취소. PIN 없음 — 취소는 안전한 방향이다(cancel_open_order 와 같은 판례).
|
||||
|
||||
⚠️ firing 마킹이 걸린 예약은 이미 키움에 스톱주문이 나갔을 수 있다. 그 주문까지 자동으로
|
||||
취소하지는 않는다 — 승인 범위를 넘는 매매 API 자동 호출이 된다. 대신 접수된 주문번호를
|
||||
응답에 실어 관리자님이 미체결 목록에서 직접 처리하게 한다.
|
||||
"""
|
||||
sidecar.guard_or_raise()
|
||||
arm = trailing.get_arm(arm_id)
|
||||
if arm is None:
|
||||
return {'ok': False, 'message': f'대기 예약 없음 ({arm_id})'}
|
||||
fired = arm.get('fired_legs') or []
|
||||
gone = trailing.remove_arm(arm_id, 'manual_cancel')
|
||||
if gone is None:
|
||||
return {'ok': False, 'message': f'대기 예약 없음 ({arm_id})'}
|
||||
ledger.append('arm_canceled', {'arm_id': arm_id, 'account': arm['account'],
|
||||
'symbol': arm['symbol'], 'qty': arm['qty'],
|
||||
'trigger_price': arm['trigger_price']})
|
||||
msg = (f'🗑 예약 트레일링 취소 ({arm_id}) — {arm["symbol_name"]} '
|
||||
f'{arm["qty"]:,}주 · 발동가 {arm["trigger_price"]:,}원')
|
||||
if fired:
|
||||
nos = ', '.join(s.get('ord_no', '') for s in fired if s.get('ord_no'))
|
||||
msg += (f'\n⚠️ 발동 처리 중 접수된 주문이 있습니다: {nos}\n'
|
||||
f'미체결 목록에서 별도로 취소해 주세요 (자동 취소하지 않습니다).')
|
||||
return {'ok': True, 'message': msg, 'placed_orders': fired}
|
||||
|
||||
|
||||
def place_trailing_legs(account: str, symbol: str, steps: list, routing_suffix: str,
|
||||
card_id: Optional[str], dry_run: bool,
|
||||
on_placed=None) -> tuple:
|
||||
"""계단별 스톱지정가 접수. 반환 (placed, failed, details).
|
||||
|
||||
얕은 계단(먼저 발동할 것)부터 순서대로 낸다 — 중간에 막혀도 가장 가까운 방어선이 먼저 걸린다.
|
||||
한 레그가 실패해도 나머지는 계속 시도한다. 이미 나간 주문을 되돌리지 않는 이유는 취소 자체도
|
||||
실패할 수 있어 상태가 더 불분명해지기 때문이다 — 대신 성공·실패를 그대로 호출측에 돌려준다.
|
||||
|
||||
`on_placed(leg)` 는 레그 하나가 접수될 때마다 즉시 호출된다. 예약 트레일링 발동 경로가
|
||||
"어느 레그까지 실제로 나갔는지" 를 디스크에 미리 적는 데 쓴다(중간에 죽었을 때의 복구 단서).
|
||||
|
||||
실주문이 나가는 유일한 계단 발주 경로다 — 등록 즉시 트레일링과 예약 발동이 이 함수를
|
||||
공유해야 두 벌로 갈라지지 않는다.
|
||||
"""
|
||||
placed, failed, details = [], [], []
|
||||
for s in steps:
|
||||
res = kiwoom_order.submit(
|
||||
account_label=account,
|
||||
side='SELL',
|
||||
symbol=symbol,
|
||||
qty=s['qty'],
|
||||
price=s['ord_uv'],
|
||||
order_type='STOP_LIMIT',
|
||||
routing_suffix=routing_suffix,
|
||||
dry_run=dry_run,
|
||||
card_id=card_id,
|
||||
stop_price=s['cond_uv'],
|
||||
)
|
||||
details.append(res)
|
||||
if res['ok']:
|
||||
# dry_run 은 주문번호가 없다 — 예약 등록 없이 접수될 내용만 확인한다.
|
||||
leg = dict(s, ord_no=res.get('ord_no', ''))
|
||||
placed.append(leg)
|
||||
if on_placed is not None:
|
||||
on_placed(leg)
|
||||
else:
|
||||
failed.append(dict(s, reason=res.get('reason', 'UNKNOWN'),
|
||||
error=str(res.get('error', ''))))
|
||||
return placed, failed, details
|
||||
|
||||
|
||||
def _submit_trailing_steps(card_obj, dry_run: bool) -> dict:
|
||||
"""트레일링 계단 카드 — 계단마다 스톱주문을 내고 예약 1건으로 묶어 등록한다.
|
||||
|
||||
키움엔 스톱지정가(trde_tp=28)로 나간다 — 트레일링은 우리 쪽 개념이다.
|
||||
얕은 계단(먼저 발동할 것)부터 접수해, 중간에 막히더라도 가장 가까운 방어선이 먼저 걸리게 한다.
|
||||
|
||||
한 레그가 실패해도 나머지는 계속 시도하고, 접수된 레그만 예약에 등록한다. 이미 나간 주문을
|
||||
되돌리지 않는 이유는 취소 자체도 실패할 수 있어 상태가 더 불분명해지기 때문이다 —
|
||||
대신 몇 단이 걸리고 몇 단이 실패했는지를 메시지에 그대로 드러낸다.
|
||||
|
||||
fill_watcher 는 걸지 않는다. 스톱 예약은 조건 도달까지 미체결로 남는 게 정상이라
|
||||
30분 미체결 알림이 오탐이 된다 — 생존·체결 감시는 trailing_monitor 가 담당한다.
|
||||
"""
|
||||
p = card_obj.payload
|
||||
steps = p.get('trail_steps') or []
|
||||
name = p.get('symbol_name', p['symbol'])
|
||||
placed, failed, details = place_trailing_legs(
|
||||
account=p['account'], symbol=p['symbol'], steps=steps,
|
||||
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id, dry_run=dry_run)
|
||||
|
||||
if not placed:
|
||||
return {'ok': False,
|
||||
'message': card.format_rejected(
|
||||
(failed[0]['reason'] if failed else 'UNKNOWN'),
|
||||
f'{name} 계단 {len(steps)}단 전부 접수 실패 — '
|
||||
+ (failed[0]['error'] if failed else '')),
|
||||
'detail': details}
|
||||
|
||||
info = {'card_id': card_obj.card_id, 'symbol_name': name, 'account': p['account'],
|
||||
'total_qty': p['qty'], 'peak': p['trail_peak'],
|
||||
'min_sell_price': p.get('min_sell_price'),
|
||||
'placed': placed, 'failed': failed, 'dry_run': dry_run,
|
||||
'res_id': None, 'register_error': None}
|
||||
|
||||
if not dry_run:
|
||||
registerable = [s for s in placed if s['ord_no']]
|
||||
if not registerable:
|
||||
# 접수는 됐는데 주문번호를 못 받았다 — 감시가 붙을 수 없으니 반드시 알린다.
|
||||
ledger.append('trailing_register_failed',
|
||||
{'card_id': card_obj.card_id, 'error': 'no ord_no', **p})
|
||||
info['register_error'] = '주문번호를 못 받아 감시를 걸지 못했습니다'
|
||||
else:
|
||||
try:
|
||||
res_trl = trailing.register_steps(
|
||||
account=p['account'], symbol=p['symbol'], symbol_name=name,
|
||||
total_qty=p['qty'], peak=p['trail_peak'], steps=registerable,
|
||||
routing_suffix=p['routing_suffix'], card_id=card_obj.card_id,
|
||||
min_sell_price=p.get('min_sell_price'),
|
||||
)
|
||||
info['res_id'] = res_trl['id']
|
||||
except Exception as e:
|
||||
# 주문은 이미 접수됨 — 등록 실패를 숨기면 트레일링이 안 도는 걸 모른다.
|
||||
ledger.append('trailing_register_failed',
|
||||
{'card_id': card_obj.card_id, 'error': repr(e), **p})
|
||||
info['register_error'] = str(e)
|
||||
|
||||
return {'ok': True, 'message': card.format_trailing_submitted(info), 'detail': details}
|
||||
|
||||
|
||||
def _submit_multi_legs(card_obj, dry_run: bool) -> dict:
|
||||
"""multi 카드(propose_trade_multi)의 레그별 키움 제출 — 레그 독립 실행·독립 보고.
|
||||
|
||||
@@ -676,6 +1006,15 @@ def modify_open_order(ord_no: Optional[str] = None,
|
||||
return {'ok': False, 'message': resolved['message'], 'code': resolved['code']}
|
||||
|
||||
row = resolved['row']
|
||||
# 스톱지정가(trde_tp=28) 계열은 정정 대상에서 뺀다. 두 가지가 동시에 깨진다:
|
||||
# ① 여기선 mdfy_cond_uv 를 안 넘겨 조건단가가 빈 값으로 나간다(조건 소실 위험).
|
||||
# ② 정정 응답 ord_no 가 신규 번호라, trailing_stops.json 의 레그 ord_no 가 어긋나
|
||||
# 그 레그의 자동 손절선 상향이 이후 전부 실패한다.
|
||||
# 바꾸고 싶으면 취소 후 재등록이 맞다.
|
||||
if row.get('stop_price'):
|
||||
return {'ok': False, 'code': 'STOP_ORDER',
|
||||
'message': f"⛔ 스톱지정가·트레일링 주문은 정정 불가 (ord_no={row['ord_no']}) — "
|
||||
'취소 후 재등록하세요'}
|
||||
if qty is None and price is None:
|
||||
return {'ok': False,
|
||||
'message': '⛔ 정정 항목 누락 — qty/price 중 최소 하나는 필요합니다'}
|
||||
@@ -690,6 +1029,27 @@ def modify_open_order(ord_no: Optional[str] = None,
|
||||
return {'ok': False,
|
||||
'message': '⛔ 시장가 미체결은 정정 불가 (가격 0). 취소 후 신규 발주하세요.'}
|
||||
|
||||
# 가격제한폭·예수금 — 신규 주문과 같은 가드를 태운다. 정정도 결국 새 가격으로 주문이 나가고,
|
||||
# propose 만 막아두면 정정이 그 검증을 우회하는 뒷문이 된다.
|
||||
# 매도 보유수량은 태우지 않는다 — 이미 접수된 주문의 수량을 그대로 두거나 줄이는 경로라
|
||||
# (웹은 price 만 보낸다) 새로 필요한 보유분이 없다.
|
||||
md = datasource.collect_market_data(row['account'], row['code'], row['side'], new_qty)
|
||||
r = guards.validate_price_band(row['side'], new_price,
|
||||
md['upper_limit'], md['lower_limit'])
|
||||
if not r.ok:
|
||||
return {'ok': False, 'code': r.code,
|
||||
'message': f"⛔ 정정 거부 [{r.code}] · ord_no={row['ord_no']} · {r.message}"}
|
||||
if row['side'] == 'BUY':
|
||||
# ⚠️ md['balance_d2'] 는 미체결 매수로 묶인 돈을 **정정 대상 주문까지 포함해** 뺀 값이다.
|
||||
# 그 주문은 지금 가격을 바꾸는 당사자라, 되돌려 더하지 않으면 제 주문에 제가 막힌다
|
||||
# (149,000×2 로 묶인 상태에서 149,100 으로 올리는 것조차 '예수금 부족'이 된다).
|
||||
# 되더하는 식은 datasource 의 locked 계산(order_price × unfilled_qty)과 같아야 정확히 상쇄된다.
|
||||
avail = md['balance_d2'] + row['order_price'] * row['unfilled_qty']
|
||||
r = guards.validate_balance_for_buy(new_qty, new_price, avail)
|
||||
if not r.ok:
|
||||
return {'ok': False, 'code': r.code,
|
||||
'message': f"⛔ 정정 거부 [{r.code}] · ord_no={row['ord_no']} · {r.message}"}
|
||||
|
||||
side_word = '매수' if row['side'] == 'BUY' else ('매도' if row['side'] == 'SELL' else row['side'])
|
||||
res = kiwoom_order.modify_order(
|
||||
account_label=row['account'],
|
||||
@@ -877,7 +1237,7 @@ def amend_trade(qty: Optional[int] = None, price: Optional[int] = None,
|
||||
return {'ok': False, 'message': card.format_rejected(sr.code, sr.message)}
|
||||
state_warning = state_eval['warning']
|
||||
|
||||
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force)
|
||||
suffix = guards.determine_routing(md['now'], md['nxt_eligible'], routing_force, new_order_type)
|
||||
|
||||
estimate = None
|
||||
final_price = new_price
|
||||
|
||||
@@ -31,6 +31,7 @@ TR_CANCEL = 'kt10003'
|
||||
# 매매구분 코드
|
||||
TRDE_TP_LIMIT = '0' # 보통가 (지정가)
|
||||
TRDE_TP_MARKET = '3' # 시장가
|
||||
TRDE_TP_STOP = '28' # 스톱지정가 — cond_uv(조건단가) 도달 시 ord_uv(지정가)로 발주
|
||||
|
||||
# 거래소 코드 (라우팅 suffix → API 거래소 구분)
|
||||
_EXCHANGE_BY_SUFFIX = {
|
||||
@@ -52,27 +53,38 @@ def _exchange_for(suffix: str) -> str:
|
||||
|
||||
def submit(account_label: str, side: str, symbol: str, qty: int,
|
||||
price: Optional[int], order_type: str, routing_suffix: str,
|
||||
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
|
||||
dry_run: bool = True, card_id: Optional[str] = None,
|
||||
stop_price: Optional[int] = None) -> dict:
|
||||
sidecar.guard_or_raise()
|
||||
|
||||
if side not in ('BUY', 'SELL'):
|
||||
raise ValueError(f'invalid side: {side}')
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT'):
|
||||
if order_type not in ('LIMIT', 'MARKET', 'AGGRESSIVE_LIMIT', 'STOP_LIMIT'):
|
||||
raise ValueError(f'invalid order_type: {order_type}')
|
||||
if order_type in ('LIMIT', 'AGGRESSIVE_LIMIT') and not price:
|
||||
raise ValueError(f'{order_type} requires price')
|
||||
if order_type == 'STOP_LIMIT' and (not price or not stop_price):
|
||||
raise ValueError('STOP_LIMIT requires price(지정가) and stop_price(조건단가)')
|
||||
|
||||
tr_id = TR_BUY if side == 'BUY' else TR_SELL
|
||||
exchange = _exchange_for(routing_suffix)
|
||||
is_market = order_type == 'MARKET'
|
||||
is_stop = order_type == 'STOP_LIMIT'
|
||||
|
||||
if is_market:
|
||||
trde_tp = TRDE_TP_MARKET
|
||||
elif is_stop:
|
||||
trde_tp = TRDE_TP_STOP
|
||||
else:
|
||||
trde_tp = TRDE_TP_LIMIT
|
||||
|
||||
body = {
|
||||
'dmst_stex_tp': exchange,
|
||||
'stk_cd': symbol,
|
||||
'ord_qty': str(qty),
|
||||
'ord_uv': '' if is_market else str(price),
|
||||
'trde_tp': TRDE_TP_MARKET if is_market else TRDE_TP_LIMIT,
|
||||
'cond_uv': '',
|
||||
'trde_tp': trde_tp,
|
||||
'cond_uv': str(stop_price) if is_stop else '',
|
||||
}
|
||||
|
||||
payload = {
|
||||
@@ -82,6 +94,7 @@ def submit(account_label: str, side: str, symbol: str, qty: int,
|
||||
'symbol': symbol,
|
||||
'qty': qty,
|
||||
'price': price,
|
||||
'stop_price': stop_price if is_stop else None,
|
||||
'order_type': order_type,
|
||||
'routing_suffix': routing_suffix,
|
||||
'exchange': exchange,
|
||||
@@ -199,13 +212,18 @@ def cancel_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
modify_qty: int, modify_price: int,
|
||||
routing_suffix: str = '',
|
||||
dry_run: bool = True, card_id: Optional[str] = None) -> dict:
|
||||
dry_run: bool = True, card_id: Optional[str] = None,
|
||||
modify_cond_price: Optional[int] = None) -> dict:
|
||||
"""미체결 주문 정정 (kt10002).
|
||||
|
||||
modify_qty / modify_price 둘 다 필수 — 키움 명세상 mdfy_qty/mdfy_uv 모두 Required.
|
||||
수량만 바꿀 땐 기존 가격, 가격만 바꿀 땐 기존 수량을 그대로 전달.
|
||||
시장가 주문은 정정 불가 (mdfy_uv 가 가격이라 0 불가) — 호출 전에 차단.
|
||||
routing_suffix 는 원주문의 거래소와 동일해야 함.
|
||||
|
||||
modify_cond_price: 스톱지정가(trde_tp=28) 주문의 조건단가 정정용 (mdfy_cond_uv).
|
||||
트레일링 스톱이 고점 갱신 시 조건단가를 올리는 데 사용. 일반 주문은 None.
|
||||
⚠️ 정정 응답의 ord_no 는 신규 주문번호 — 다음 정정은 그 번호를 orig_ord_no 로 써야 함.
|
||||
"""
|
||||
sidecar.guard_or_raise()
|
||||
|
||||
@@ -217,6 +235,8 @@ def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
raise ValueError(f'modify_qty must be > 0 (got {modify_qty})')
|
||||
if modify_price <= 0:
|
||||
raise ValueError(f'modify_price must be > 0 — 시장가 주문은 정정 불가, 취소 후 신규 발주')
|
||||
if modify_cond_price is not None and modify_cond_price <= 0:
|
||||
raise ValueError(f'modify_cond_price must be > 0 (got {modify_cond_price})')
|
||||
|
||||
exchange = _exchange_for(routing_suffix)
|
||||
body = {
|
||||
@@ -225,7 +245,7 @@ def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
'stk_cd': symbol,
|
||||
'mdfy_qty': str(modify_qty),
|
||||
'mdfy_uv': str(modify_price),
|
||||
'mdfy_cond_uv': '',
|
||||
'mdfy_cond_uv': str(modify_cond_price) if modify_cond_price else '',
|
||||
}
|
||||
|
||||
payload = {
|
||||
@@ -235,6 +255,7 @@ def modify_order(account_label: str, orig_ord_no: str, symbol: str,
|
||||
'orig_ord_no': orig_ord_no,
|
||||
'modify_qty': modify_qty,
|
||||
'modify_price': modify_price,
|
||||
'modify_cond_price': modify_cond_price,
|
||||
'routing_suffix': routing_suffix,
|
||||
'exchange': exchange,
|
||||
'tr_id': TR_MODIFY,
|
||||
|
||||
@@ -81,6 +81,15 @@ def idempotency_hash(account: str, symbol: str, side: str, qty: int, price: int
|
||||
return hashlib.sha256(key.encode('utf-8')).hexdigest()[:16]
|
||||
|
||||
|
||||
# 멱등성 매칭 대상 — **이중 발주 위험이 실재하는 이벤트만** 본다.
|
||||
# submitted : 키움이 접수했다 (확정)
|
||||
# failed : 네트워크 에러 — 나갔는지 모른다 (가장 위험, 반드시 차단)
|
||||
# rejected(브로커 거부·idem 중복)·dryrun 은 접수되지 않은 게 확정이라 재시도를 막지 않는다.
|
||||
# ⚠️ 이게 없으면 키움이 거부한 직후 같은 주문을 다시 내는 게 90초간 IDEMPOTENCY_DUP 으로
|
||||
# 막혀, 관리자님이 영문 모를 두 번째 실패를 본다 (2026-09-21 SOR 스톱 거부 때 실측).
|
||||
IDEM_MATCH_EVENTS = ('submitted', 'failed')
|
||||
|
||||
|
||||
def find_recent_idempotency(h: str, within_seconds: int = 90) -> Optional[dict]:
|
||||
path = _ledger_path()
|
||||
if not path.exists():
|
||||
@@ -99,7 +108,8 @@ def find_recent_idempotency(h: str, within_seconds: int = 90) -> Optional[dict]:
|
||||
continue
|
||||
if ts < cutoff:
|
||||
return None
|
||||
if rec.get('payload', {}).get('idem_hash') == h:
|
||||
if (rec.get('event') in IDEM_MATCH_EVENTS
|
||||
and rec.get('payload', {}).get('idem_hash') == h):
|
||||
return rec
|
||||
return None
|
||||
|
||||
|
||||
@@ -4,14 +4,22 @@
|
||||
"last_updated": "2026-05-06",
|
||||
"note": "변경 시 별도 커밋. guards 단위테스트가 이 파일을 검증. 손으로 풀지 말고 항상 이 파일을 통해 조정."
|
||||
},
|
||||
|
||||
"enabled": false,
|
||||
"kill_switch_file": "state/orders_disabled",
|
||||
|
||||
"accounts_whitelist": ["일반", "ISA", "가희_일반", "가희_ISA"],
|
||||
"owner_accounts": ["일반", "ISA"],
|
||||
"spouse_accounts": ["가희_일반", "가희_ISA"],
|
||||
|
||||
"accounts_whitelist": [
|
||||
"일반",
|
||||
"ISA",
|
||||
"가희_일반",
|
||||
"가희_ISA"
|
||||
],
|
||||
"owner_accounts": [
|
||||
"일반",
|
||||
"ISA"
|
||||
],
|
||||
"spouse_accounts": [
|
||||
"가희_일반",
|
||||
"가희_ISA"
|
||||
],
|
||||
"limits": {
|
||||
"single_order_max_won": null,
|
||||
"daily_total_max_won": null,
|
||||
@@ -19,11 +27,9 @@
|
||||
"price_band_pct_soft": null,
|
||||
"price_band_pct_hard": 30
|
||||
},
|
||||
|
||||
"delays": {
|
||||
"between_orders_seconds": 5
|
||||
},
|
||||
|
||||
"pin": {
|
||||
"owner_length": 4,
|
||||
"owner_charset": "digits",
|
||||
@@ -33,7 +39,6 @@
|
||||
"expiry_seconds": 120,
|
||||
"max_attempts": 1
|
||||
},
|
||||
|
||||
"trading_hours": {
|
||||
"tz": "Asia/Seoul",
|
||||
"krx_regular_start": "09:00:30",
|
||||
@@ -44,27 +49,36 @@
|
||||
"nxt_pre_end": "09:00:00",
|
||||
"nxt_after_start": "15:30:00",
|
||||
"nxt_after_end": "20:00:00",
|
||||
"krx_after_start": "16:00:00",
|
||||
"block_outside": true,
|
||||
"holiday_state_file": "state/market_holidays.json"
|
||||
},
|
||||
|
||||
"routing": {
|
||||
"default": "AL",
|
||||
"force_options": ["AL", "NX", "KRX"],
|
||||
"force_options": [
|
||||
"AL",
|
||||
"NX",
|
||||
"KRX"
|
||||
],
|
||||
"suffix_AL": "_AL",
|
||||
"suffix_NX": "_NX",
|
||||
"suffix_KRX": ""
|
||||
},
|
||||
|
||||
"market_order": {
|
||||
"allowed": true,
|
||||
"natural_language_market": ["시장가"],
|
||||
"natural_language_aggressive_limit": ["지금 바로", "즉시", "빨리", "당장"],
|
||||
"natural_language_market": [
|
||||
"시장가"
|
||||
],
|
||||
"natural_language_aggressive_limit": [
|
||||
"지금 바로",
|
||||
"즉시",
|
||||
"빨리",
|
||||
"당장"
|
||||
],
|
||||
"aggressive_limit_ticks": 1,
|
||||
"show_orderbook_depth": 3,
|
||||
"estimate_slippage": true
|
||||
},
|
||||
|
||||
"guards": {
|
||||
"block_market_holidays": true,
|
||||
"block_trading_halt": true,
|
||||
@@ -74,9 +88,13 @@
|
||||
"require_position_check_for_sell": true,
|
||||
"block_market_order_when_orderbook_thin": false
|
||||
},
|
||||
|
||||
"card": {
|
||||
"highlight_fields": ["side", "account", "symbol_name", "price"],
|
||||
"highlight_fields": [
|
||||
"side",
|
||||
"account",
|
||||
"symbol_name",
|
||||
"price"
|
||||
],
|
||||
"highlight_marker": "▶",
|
||||
"highlight_bold_markdown": true,
|
||||
"buy_emoji": "🛒",
|
||||
@@ -86,13 +104,35 @@
|
||||
"orderbook_depth": 3,
|
||||
"show_balance_ratio": true
|
||||
},
|
||||
|
||||
"ledger": {
|
||||
"log_file": "state/order_log.jsonl",
|
||||
"events": ["card_issued", "pin_issued", "amended", "approved", "rejected", "expired", "canceled", "submitted", "filled", "partial", "failed", "dryrun", "cancel_submitted", "cancel_rejected", "cancel_confirmed", "cancel_unconfirmed_timeout", "modify_submitted", "modify_rejected"],
|
||||
"events": [
|
||||
"card_issued",
|
||||
"pin_issued",
|
||||
"amended",
|
||||
"approved",
|
||||
"rejected",
|
||||
"expired",
|
||||
"canceled",
|
||||
"submitted",
|
||||
"filled",
|
||||
"partial",
|
||||
"failed",
|
||||
"dryrun",
|
||||
"cancel_submitted",
|
||||
"cancel_rejected",
|
||||
"cancel_confirmed",
|
||||
"cancel_unconfirmed_timeout",
|
||||
"modify_submitted",
|
||||
"modify_rejected",
|
||||
"trailing_register_failed",
|
||||
"arm_registered",
|
||||
"arm_fired",
|
||||
"arm_fire_failed",
|
||||
"arm_canceled"
|
||||
],
|
||||
"mask_token_in_logs": true
|
||||
},
|
||||
|
||||
"telegram": {
|
||||
"dm_only": true,
|
||||
"chat_id_whitelist_env": "OPENCLAW_OWNER_CHAT_ID",
|
||||
|
||||
@@ -13,7 +13,7 @@ import time
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
from orders import fill_watcher
|
||||
from orders import fill_watcher, ledger
|
||||
from orders.fill_watcher import Tracked
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@ class FillWatcherPollTests(unittest.TestCase):
|
||||
"""_FillWatcher._poll_once 직접 호출로 격리 (시간 의존 X)."""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
|
||||
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
|
||||
mock.patch.object(ledger, 'append', return_value=None).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
fill_watcher._reset_for_test()
|
||||
self.sent = []
|
||||
self.fetch = mock.MagicMock(return_value=[])
|
||||
@@ -130,6 +134,10 @@ class CancelWatcherTests(unittest.TestCase):
|
||||
"""cancel kind 회귀 — ka10075 폴링으로 원주문이 사라지면 확정."""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
|
||||
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
|
||||
mock.patch.object(ledger, 'append', return_value=None).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
fill_watcher._reset_for_test()
|
||||
self.sent = []
|
||||
self.fetch_exec = mock.MagicMock(return_value=[])
|
||||
@@ -243,6 +251,10 @@ class FillWatcherQueueIOTests(unittest.TestCase):
|
||||
"""큐 파일 read/append/persist."""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
|
||||
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
|
||||
mock.patch.object(ledger, 'append', return_value=None).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
|
||||
@@ -303,6 +315,10 @@ class FillWatcherWatchEntryTests(unittest.TestCase):
|
||||
"""watch() = 큐 append + 데몬 ensure_running. subprocess.Popen mock."""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
|
||||
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
|
||||
mock.patch.object(ledger, 'append', return_value=None).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
# subprocess.Popen mock — 실제 데몬 fork 막음
|
||||
@@ -384,6 +400,10 @@ class FillWatcherDaemonAliveTests(unittest.TestCase):
|
||||
"""is_daemon_alive — PID 파일 stale 검출."""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
|
||||
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
|
||||
mock.patch.object(ledger, 'append', return_value=None).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
|
||||
@@ -412,6 +432,10 @@ class FillWatcherSyncFromQueueTests(unittest.TestCase):
|
||||
"""sync_from_queue — 큐 → _tracked 양방향 동기화."""
|
||||
|
||||
def setUp(self):
|
||||
# ⚠️ ledger.append 를 막지 않으면 _poll_once 가 **운영 원장**
|
||||
# (state/order_log.jsonl)에 테스트 이벤트를 실제로 쓴다.
|
||||
mock.patch.object(ledger, 'append', return_value=None).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
fill_watcher._reset_for_test()
|
||||
self.addCleanup(fill_watcher._reset_for_test)
|
||||
|
||||
|
||||
@@ -61,8 +61,14 @@ class SessionTests(unittest.TestCase):
|
||||
|
||||
def test_nxt_after(self):
|
||||
self.assertEqual(guards.session_at(t(15, 30)), 'NXT_AFTER')
|
||||
self.assertEqual(guards.session_at(t(18, 0)), 'NXT_AFTER')
|
||||
self.assertEqual(guards.session_at(t(19, 59, 59)), 'NXT_AFTER')
|
||||
self.assertEqual(guards.session_at(t(15, 59, 59)), 'NXT_AFTER')
|
||||
|
||||
def test_krx_aftermarket(self):
|
||||
"""16:00~20:00 = KRX 애프터마켓 + NXT 애프터마켓 (2026-09-14 시행)."""
|
||||
self.assertEqual(guards.session_at(t(16, 0)), 'AFTER')
|
||||
self.assertEqual(guards.session_at(t(18, 0)), 'AFTER')
|
||||
self.assertEqual(guards.session_at(t(19, 59, 59)), 'AFTER')
|
||||
self.assertEqual(guards.session_at(t(20, 0)), 'CLOSED')
|
||||
|
||||
def test_closed_after_after(self):
|
||||
self.assertEqual(guards.session_at(t(20, 0)), 'CLOSED')
|
||||
@@ -94,9 +100,13 @@ class TradingHoursTests(unittest.TestCase):
|
||||
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
|
||||
|
||||
def test_nxt_after_not_eligible(self):
|
||||
r = guards.validate_trading_hours(t(18, 0), False, False)
|
||||
r = guards.validate_trading_hours(t(15, 45), False, False)
|
||||
self.assertEqual(r.code, 'NXT_NOT_ELIGIBLE')
|
||||
|
||||
def test_aftermarket_allows_non_nxt(self):
|
||||
"""애프터마켓엔 KRX 가 NXT 미상장 종목을 받는다 — 여기서 막으면 안 된다."""
|
||||
self.assertTrue(guards.validate_trading_hours(t(18, 0), False, False).ok)
|
||||
|
||||
def test_krx_close_no_nxt_required(self):
|
||||
self.assertTrue(guards.validate_trading_hours(t(15, 25), False, False).ok)
|
||||
|
||||
@@ -114,7 +124,12 @@ class RoutingTests(unittest.TestCase):
|
||||
self.assertEqual(guards.determine_routing(t(8, 30), True, None), '_NX')
|
||||
|
||||
def test_nxt_after(self):
|
||||
self.assertEqual(guards.determine_routing(t(18, 0), True, None), '_NX')
|
||||
self.assertEqual(guards.determine_routing(t(15, 45), True, None), '_NX')
|
||||
|
||||
def test_aftermarket_routes_to_krx(self):
|
||||
"""애프터마켓은 검증된 KRX 경로만 쓴다 — SOR 주문 라우팅은 미검증."""
|
||||
self.assertEqual(guards.determine_routing(t(18, 0), True, None), '')
|
||||
self.assertEqual(guards.determine_routing(t(18, 0), False, None), '')
|
||||
|
||||
def test_krx_close(self):
|
||||
self.assertEqual(guards.determine_routing(t(15, 25), False, None), '')
|
||||
@@ -133,6 +148,31 @@ class RoutingTests(unittest.TestCase):
|
||||
guards.determine_routing(t(21, 0), True, None)
|
||||
|
||||
|
||||
class StopRoutingTests(unittest.TestCase):
|
||||
"""스톱 계열은 SOR(_AL) 로 못 낸다 — 키움 407022 (2026-09-21 삼성전자 실측)."""
|
||||
|
||||
def test_stop_types_downgrade_al_to_krx(self):
|
||||
for ot in ('STOP_LIMIT', 'TRAILING_STOP', 'TRAILING_ARM'):
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, None, ot), '', ot)
|
||||
|
||||
def test_force_al_also_downgraded(self):
|
||||
# force 는 사람의 명시 지정이지만 SOR 자체가 스톱을 안 받는다
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, 'AL', 'STOP_LIMIT'), '')
|
||||
|
||||
def test_nxt_session_kept(self):
|
||||
# NXT 단독 시간대는 KRX 가 닫혀 강등할 곳이 없다 — 시도는 그대로 둔다
|
||||
self.assertEqual(guards.determine_routing(t(8, 30), True, None, 'STOP_LIMIT'), '_NX')
|
||||
self.assertEqual(guards.determine_routing(t(15, 45), True, None, 'STOP_LIMIT'), '_NX')
|
||||
|
||||
def test_non_stop_types_unaffected(self):
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, None, 'LIMIT'), '_AL')
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, None, 'MARKET'), '_AL')
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), True, None, None), '_AL')
|
||||
|
||||
def test_non_nxt_symbol_already_krx(self):
|
||||
self.assertEqual(guards.determine_routing(t(10, 0), False, None, 'STOP_LIMIT'), '')
|
||||
|
||||
|
||||
# ---------- 가격 가드 ----------
|
||||
|
||||
class PriceBandTests(unittest.TestCase):
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""예약 트레일링(TRAILING_ARM) propose·승인 경로 회귀 테스트 (2026-08-25).
|
||||
|
||||
datasource·sidecar·pin_store·ledger 를 mock 해서 키움을 전혀 건드리지 않는다.
|
||||
가장 중요한 확인은 **거부 케이스에서 카드가 발행되지 않는 것** — 카드가 나가면 PIN 한 번으로
|
||||
발동 조건까지 확정되므로, 걸러야 할 입력이 카드 단계를 통과하면 안 된다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
from orders import handler, ledger
|
||||
from orders import trailing as t
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
STEPS = [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50}, {'pct': 30, 'cum': 100}]
|
||||
|
||||
|
||||
def _md(cur=70000):
|
||||
return {
|
||||
'now': datetime(2026, 8, 25, 10, 0, tzinfo=KST),
|
||||
'is_holiday': False,
|
||||
'nxt_eligible': True,
|
||||
'current_price': cur,
|
||||
'prev_close': cur,
|
||||
'upper_limit': int(cur * 1.3),
|
||||
'lower_limit': int(cur * 0.7),
|
||||
'halt': False,
|
||||
'vi': False,
|
||||
'orderbook': {'asks': [{'price': cur + 100, 'qty': 200}],
|
||||
'bids': [{'price': cur, 'qty': 200}]},
|
||||
'broker_executions': [],
|
||||
'broker_query_error': None,
|
||||
'position_qty': 100,
|
||||
'balance_d2': 5_000_000,
|
||||
'stock_meta': None,
|
||||
}
|
||||
|
||||
|
||||
class ArmProposeTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
|
||||
self._orig = (t.STATE_FILE, t._LOCK_FILE)
|
||||
t.STATE_FILE = self.tmp
|
||||
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
|
||||
|
||||
self.collect = mock.patch.object(handler.datasource, 'collect_market_data',
|
||||
return_value=_md()).start()
|
||||
mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None).start()
|
||||
mock.patch.object(handler, '_sweep_expired_and_notify', return_value=None).start()
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_terminal_event', return_value=None).start()
|
||||
mock.patch.object(ledger, 'last_event_for_symbol', return_value=None).start()
|
||||
fake = mock.MagicMock()
|
||||
fake.card_id, fake.pin, fake.expiry_seconds, fake.account_label = 'TEST', '1234', 120, '일반'
|
||||
self.issue = mock.patch.object(handler._pin_store, 'issue', return_value=fake).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def tearDown(self):
|
||||
t.STATE_FILE, t._LOCK_FILE = self._orig
|
||||
shutil.rmtree(self.tmp.parent, ignore_errors=True)
|
||||
|
||||
def _propose(self, **kw):
|
||||
base = dict(account='일반', side='SELL', symbol='005930', symbol_name='삼성전자',
|
||||
qty=100, order_type='TRAILING_ARM', trail_steps=STEPS,
|
||||
trigger_price=80000)
|
||||
base.update(kw)
|
||||
return handler.propose_trade(**base)
|
||||
|
||||
def _assert_rejected(self, res, code):
|
||||
self.assertFalse(res['ok'], msg=res.get('message'))
|
||||
self.assertIn(code, res['message'])
|
||||
self.issue.assert_not_called() # 거부는 카드를 남기지 않아야 한다
|
||||
|
||||
# ── 통과 경로 ──
|
||||
def test_valid_arm_issues_card_with_definition_only(self):
|
||||
res = self._propose(min_sell_price=60000)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = self.issue.call_args[0][1]
|
||||
self.assertEqual(payload['order_type'], 'TRAILING_ARM')
|
||||
self.assertEqual(payload['trigger_price'], 80000)
|
||||
self.assertIsNone(payload['price']) # 나가는 주문이 없다
|
||||
# trail_steps 는 정의만, 미리보기는 별도 키 — 발동 경로가 옛 가격을 재사용하지 못하게.
|
||||
self.assertNotIn('cond_uv', payload['trail_steps'][0])
|
||||
self.assertIn('cond_uv', payload['trail_preview'][0])
|
||||
|
||||
def test_preview_uses_trigger_not_current_price(self):
|
||||
res = self._propose()
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
payload = self.issue.call_args[0][1]
|
||||
expect = t.compute_step_levels(80000, t.normalize_steps(STEPS), None)
|
||||
self.assertEqual([s['cond_uv'] for s in payload['trail_preview']],
|
||||
[s['cond_uv'] for s in expect])
|
||||
|
||||
def test_trigger_above_todays_upper_limit_is_allowed(self):
|
||||
"""발동가는 가격제한폭 검증을 받지 않는다 — 실제 발주는 발동 시점 현재가로 계산된다.
|
||||
|
||||
arm 이 당일 유효가 된 뒤로는 '오늘 상한가 초과 = 오늘 도달 불가'지만 **막지 않고
|
||||
경고만** 한다(2026-08-25 관리자님 결정). 통과가 의도된 동작이라 고정해 둔다.
|
||||
"""
|
||||
res = self._propose(trigger_price=200000) # 현재가 70,000 · 상한가 91,000
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
|
||||
def test_trigger_above_upper_limit_warns_on_card(self):
|
||||
"""막지는 않되 승인 전에 '오늘 도달 불가'를 반드시 알린다."""
|
||||
res = self._propose(trigger_price=200000)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
self.assertIn('오늘 상한가', res['card_message'])
|
||||
self.assertIn('만료', res['card_message'])
|
||||
# 상한가 이내면 경고가 없어야 한다 (남발 금지)
|
||||
res2 = self._propose(trigger_price=85000) # 상한가 91,000 이내
|
||||
self.assertTrue(res2['ok'], msg=res2.get('message'))
|
||||
self.assertNotIn('오늘 상한가', res2['card_message'])
|
||||
|
||||
# ── 거부 경로 ──
|
||||
def test_buy_rejected(self):
|
||||
self._assert_rejected(self._propose(side='BUY'), 'ARM_SELL_ONLY')
|
||||
|
||||
def test_missing_trigger_rejected(self):
|
||||
self._assert_rejected(self._propose(trigger_price=None), 'ARM_TRIGGER')
|
||||
|
||||
def test_zero_trigger_rejected(self):
|
||||
self._assert_rejected(self._propose(trigger_price=0), 'ARM_TRIGGER')
|
||||
|
||||
def test_already_reached_rejected(self):
|
||||
self._assert_rejected(self._propose(trigger_price=70000), 'ARM_ALREADY_REACHED')
|
||||
self._assert_rejected(self._propose(trigger_price=65000), 'ARM_ALREADY_REACHED')
|
||||
|
||||
def test_floor_above_trigger_rejected(self):
|
||||
self._assert_rejected(self._propose(min_sell_price=85000), 'ARM_IMMEDIATE')
|
||||
|
||||
def test_budget_rejected(self):
|
||||
self._assert_rejected(self._propose(qty=None, budget=1_000_000), 'TRAIL_BUDGET')
|
||||
|
||||
def test_bad_steps_rejected(self):
|
||||
self._assert_rejected(self._propose(trail_steps=[{'pct': 20, 'cum': 20},
|
||||
{'pct': 10, 'cum': 50}]), 'TRAIL_STEPS')
|
||||
|
||||
def test_qty_too_small_rejected(self):
|
||||
# qty=0 은 '미입력'(None)이 아니라 '값 0' 이라 MISSING_QTY 를 지나 계단 배분에서 걸린다.
|
||||
self._assert_rejected(self._propose(qty=0), 'TRAIL_QTY')
|
||||
|
||||
def test_no_qty_rejected(self):
|
||||
self._assert_rejected(self._propose(qty=None), 'MISSING_QTY')
|
||||
|
||||
def test_duplicate_arm_rejected(self):
|
||||
t.register_arm(account='일반', symbol='005930', symbol_name='삼성전자', qty=10,
|
||||
trigger_price=90000, steps=t.normalize_steps(STEPS))
|
||||
self._assert_rejected(self._propose(), 'TRAIL_DUPLICATE')
|
||||
|
||||
def test_existing_trailing_blocks_arm(self):
|
||||
"""같은 계좌·종목에 활성 트레일링이 있으면 예약도 막는다 — 같은 주식을 두 번 팔 수 없다."""
|
||||
st = t.normalize_steps(STEPS)
|
||||
lv = t.compute_step_levels(70000, st, None)
|
||||
qtys = t.allocate_step_qty(100, [s['weight'] for s in st])
|
||||
legs = [dict(s, qty=q, ord_no=f'O{s["n"]}') for s, q in zip(lv, qtys)]
|
||||
t.register_steps(account='일반', symbol='005930', symbol_name='삼성전자',
|
||||
total_qty=100, peak=70000, steps=legs)
|
||||
self._assert_rejected(self._propose(), 'TRAIL_DUPLICATE')
|
||||
|
||||
def test_arm_blocks_plain_trailing_too(self):
|
||||
"""반대 방향도 막혀야 한다 — 대기 예약이 있으면 일반 트레일링을 새로 걸 수 없다."""
|
||||
t.register_arm(account='일반', symbol='005930', symbol_name='삼성전자', qty=10,
|
||||
trigger_price=90000, steps=t.normalize_steps(STEPS))
|
||||
res = handler.propose_trade(account='일반', side='SELL', symbol='005930',
|
||||
symbol_name='삼성전자', qty=100,
|
||||
order_type='TRAILING_STOP', trail_steps=STEPS)
|
||||
self._assert_rejected(res, 'TRAIL_DUPLICATE')
|
||||
|
||||
def test_holiday_now_actually_blocks(self):
|
||||
"""guards.is_today_holiday 의 dict 미인식 버그를 고친 뒤라야 이게 막힌다."""
|
||||
self.collect.return_value = dict(_md(), is_holiday=True)
|
||||
res = self._propose()
|
||||
self.assertFalse(res['ok'], msg=res.get('message'))
|
||||
self.assertIn('HOLIDAY', res['message'])
|
||||
|
||||
|
||||
class ArmRegisterTests(unittest.TestCase):
|
||||
"""PIN 승인 → 대기 예약 등록. 키움 호출이 0건이어야 한다."""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'trailing_stops.json'
|
||||
self._orig = (t.STATE_FILE, t._LOCK_FILE)
|
||||
t.STATE_FILE = self.tmp
|
||||
t._LOCK_FILE = self.tmp.with_suffix('.json.lock')
|
||||
mock.patch.object(handler.ledger, 'append', return_value=None).start()
|
||||
self.submit = mock.patch.object(handler.kiwoom_order, 'submit').start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
self.card = mock.MagicMock()
|
||||
self.card.card_id = 'AB3D'
|
||||
self.card.payload = {
|
||||
'account': '일반', 'side': 'SELL', 'symbol': '005930', 'symbol_name': '삼성전자',
|
||||
'qty': 100, 'price': None, 'order_type': 'TRAILING_ARM', 'routing_suffix': '_AL',
|
||||
'trigger_price': 80000, 'trail_steps': t.normalize_steps(STEPS),
|
||||
'trail_preview': [], 'min_sell_price': 60000,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
t.STATE_FILE, t._LOCK_FILE = self._orig
|
||||
shutil.rmtree(self.tmp.parent, ignore_errors=True)
|
||||
|
||||
def test_register_places_no_order(self):
|
||||
res = handler._register_trailing_arm(self.card, dry_run=False)
|
||||
self.assertTrue(res['ok'], msg=res.get('message'))
|
||||
self.submit.assert_not_called()
|
||||
arms = t.list_arms()
|
||||
self.assertEqual(len(arms), 1)
|
||||
self.assertEqual((arms[0]['trigger_price'], arms[0]['card_id']), (80000, 'AB3D'))
|
||||
self.assertIn('발동가에 닿으면 PIN 재확인 없이', res['message'])
|
||||
|
||||
def test_dry_run_writes_nothing(self):
|
||||
res = handler._register_trailing_arm(self.card, dry_run=True)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
self.submit.assert_not_called()
|
||||
|
||||
def test_cancel_removes_arm(self):
|
||||
handler._register_trailing_arm(self.card, dry_run=False)
|
||||
arm_id = t.list_arms()[0]['id']
|
||||
with mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None):
|
||||
res = handler.cancel_trailing_arm(arm_id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertEqual(t.list_arms(), [])
|
||||
|
||||
def test_cancel_warns_about_orphan_orders(self):
|
||||
"""발동 중 접수된 주문이 있으면 자동 취소하지 않고 주문번호를 알려준다."""
|
||||
handler._register_trailing_arm(self.card, dry_run=False)
|
||||
arm_id = t.list_arms()[0]['id']
|
||||
t.mark_arm_firing(arm_id)
|
||||
t.record_arm_leg(arm_id, {'n': 1, 'qty': 20, 'cond_uv': 72000, 'ord_uv': 71900,
|
||||
'ord_no': '0374065'})
|
||||
with mock.patch.object(handler.sidecar, 'guard_or_raise', return_value=None):
|
||||
res = handler.cancel_trailing_arm(arm_id)
|
||||
self.assertTrue(res['ok'])
|
||||
self.assertIn('0374065', res['message'])
|
||||
self.assertIn('별도로 취소', res['message'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||
unittest.main(verbosity=2)
|
||||
@@ -98,3 +98,46 @@ class SubmitResponseTests(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
|
||||
class IdempotencyMatchTests(unittest.TestCase):
|
||||
"""멱등성은 '이중 발주 위험이 실재하는' 이벤트만 본다.
|
||||
|
||||
2026-09-21: SOR 스톱 거부 직후 같은 주문을 다시 냈더니 IDEMPOTENCY_DUP 으로 또 막혔다.
|
||||
키움이 거부한 주문은 접수되지 않은 게 확정이라 재시도를 막을 이유가 없다.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
self.tmp = Path(tempfile.mkdtemp()) / 'order_log.jsonl'
|
||||
mock.patch.object(ledger, '_ledger_path', return_value=self.tmp).start()
|
||||
self.addCleanup(mock.patch.stopall)
|
||||
|
||||
def _write(self, event: str, h: str = 'HASH1'):
|
||||
import json
|
||||
from datetime import datetime
|
||||
with self.tmp.open('a', encoding='utf-8') as f:
|
||||
f.write(json.dumps({'ts': datetime.now(ledger.KST).isoformat(),
|
||||
'event': event, 'payload': {'idem_hash': h}}) + '\n')
|
||||
|
||||
def test_submitted_blocks(self):
|
||||
self._write('submitted')
|
||||
self.assertIsNotNone(ledger.find_recent_idempotency('HASH1'))
|
||||
|
||||
def test_network_failure_blocks(self):
|
||||
"""나갔는지 모르는 상태 — 가장 위험하므로 반드시 차단."""
|
||||
self._write('failed')
|
||||
self.assertIsNotNone(ledger.find_recent_idempotency('HASH1'))
|
||||
|
||||
def test_broker_reject_does_not_block(self):
|
||||
self._write('rejected')
|
||||
self.assertIsNone(ledger.find_recent_idempotency('HASH1'))
|
||||
|
||||
def test_dryrun_does_not_block(self):
|
||||
self._write('dryrun')
|
||||
self.assertIsNone(ledger.find_recent_idempotency('HASH1'))
|
||||
|
||||
def test_other_hash_ignored(self):
|
||||
self._write('submitted', h='OTHER')
|
||||
self.assertIsNone(ledger.find_recent_idempotency('HASH1'))
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,803 @@
|
||||
"""트레일링 스톱 예약 상태 관리 (계단식 분할 매도).
|
||||
|
||||
키움에 REST 트레일링 주문 TR 이 없어서, 스톱지정가(trde_tp=28) 주문을 실제로 걸어두고
|
||||
고점이 갱신될 때마다 조건단가를 kt10002 정정으로 올려 트레일링을 구현한다.
|
||||
|
||||
**예약 1건 = 계단(레그) N개 = 키움 스톱주문 N건.** 고점 대비 하락률이 깊어질수록 더 많이
|
||||
파는 계단식 청산이다 (예: −10% 20% / −20% 50% / −30% 전량). 계단별로 수량과 조건단가가
|
||||
다른 스톱주문을 동시에 걸어두고, 고점이 오르면 살아있는 레그를 전부 상향 정정한다.
|
||||
|
||||
주문이 키움 서버에 있으므로 감시 루프가 죽어도 계단별 손절선은 살아있다.
|
||||
감시 루프가 하는 일은 "이미 승인된 주문의 조건단가 상향"뿐 — 신규 발주·수량 변경 없음.
|
||||
|
||||
⚠️ 계단 비중은 관리자님이 **누적**으로 입력하고(20/50/100 = 전량), 내부에는 계단별
|
||||
**추가분**(20/30/50)으로 환산해 저장한다. 주문 수량이 곧 추가분이기 때문이다.
|
||||
|
||||
⚠️ 한 계단이 체결된 뒤 반등해 신고점을 찍어도 **남은 계단만 새 고점 기준으로 따라 올린다**.
|
||||
남은 수량을 계단 비율대로 재배치하려면 기존 주문 취소 + 신규 발주가 필요한데, 그러면
|
||||
감시 루프에 자동 발주 경로가 생긴다(매매 자동 트리거 금지). 2026-07-31 관리자님 결정.
|
||||
|
||||
⚠️ kt10002 정정 응답의 ord_no 는 신규 주문번호다. 정정할 때마다 ord_no 를 갱신하지 않으면
|
||||
두 번째 정정부터 orig_ord_no 가 틀려서 전부 실패한다. commit_step_modify 가 이걸 담당.
|
||||
|
||||
상태는 파일(state/trailing_stops.json)에 저장 — 웹·감시 루프가 각각 다른 프로세스라
|
||||
같은 예약 목록을 봐야 한다. 동시성은 fcntl flock 으로 직렬화 (pin.py 와 같은 패턴).
|
||||
|
||||
── 대기 예약 (arms) — 예약 트레일링, 2026-08-25 ──────────────────────────────
|
||||
|
||||
같은 파일에 `arms` 키가 하나 더 있다. **발동가에 닿으면 그때 트레일링을 시작하는 예약**으로,
|
||||
`reservations`(이미 키움에 주문이 나간 것)와 성질이 정반대다:
|
||||
|
||||
reservations : 주문이 키움에 있음 → 감시 루프가 죽어도 손절선은 살아있음, 장 마감에 소멸
|
||||
arms : 주문이 없고 이 파일에만 있음 → 감시가 죽으면 발동 안 함, **당일만 유효**
|
||||
|
||||
⚠️ **arm 도 스톱주문과 같은 수명(등록일 당일)이다** — 2026-08-25 관리자님 결정으로 무기한에서
|
||||
바뀌었다. 거래소가 지워주는 스톱주문과 달리 arm 은 우리 파일에만 있으니 우리가 지운다:
|
||||
`is_arm_expired` 판정 + 감시 루프의 다음 거래일 첫 사이클 정리.
|
||||
|
||||
여기에 세 번째 키 `recent` 가 더 있다 — 장 마감 소멸·만료된 예약의 설정을 짧게 보관해
|
||||
자산웹이 "같은 설정으로 다시 등록" 버튼을 띄울 수 있게 하는 **UI 편의용 링버퍼**다.
|
||||
감사 기록이 아니다(그쪽은 state/order_log.jsonl, 무한 append).
|
||||
|
||||
⚠️ **두 갈래를 한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록"이라는 하나의 원자적
|
||||
전이여야 해서다.** promote_arm 이 락 하나 안에서 os.replace 1회로 끝낸다. 파일을 쪼개면
|
||||
락을 중첩해야 하고(데드락), 중간에 죽으면 둘 다 존재하거나 둘 다 사라진다.
|
||||
|
||||
⚠️ 그 대가로 **_write 는 None 인 쪽을 디스크에서 다시 읽어 보존**한다. 이 계약이 깨지면
|
||||
한쪽 쓰기가 나머지 두 갈래를 조용히 전멸시킨다 — reservations 쪽 쓰기만 6곳이다.
|
||||
`_read_doc` 이 세 키를 하드 정규화하므로 **키를 늘릴 때 _read_doc 과 _write 를 반드시
|
||||
같이** 고쳐야 한다 (테스트로 고정해 둠).
|
||||
|
||||
⚠️ arm 의 steps 에는 **가격·주수를 저장하지 않는다**(정의 n/pct/cum/weight 만). routing_suffix
|
||||
도 저장하지 않는다. 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고, 무기한 대기라 등록 시점
|
||||
세션(KRX)과 발동 시점 세션(NXT)이 달라 옛 suffix 로 발주하면 거부된다. 둘 다 발동 시점에
|
||||
plan_arm_fire / guards.determine_routing 으로 새로 계산한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from .guards import after_trading_day, tick_size
|
||||
|
||||
WORKSPACE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
STATE_FILE = WORKSPACE_ROOT / 'state' / 'trailing_stops.json'
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
|
||||
# 트레일 폭 허용 범위 (%). 너무 좁으면 정상 출렁임에 즉시 털리고, 너무 넓으면 스톱 의미가 없다.
|
||||
# ⚠️ 상한을 바꾸면 **세 곳**이 같이 움직여야 한다 — 여기(서버 검증) /
|
||||
# behive_web 트레일 폭 드롭다운 옵션 / behive_web `trailBlockReason` 의 범위 체크.
|
||||
# 하나만 고치면 드롭다운에서 고를 수 있는데 서버가 거부하거나 그 반대가 된다.
|
||||
MIN_TRAIL_PCT = 0.5
|
||||
MAX_TRAIL_PCT = 30.0
|
||||
|
||||
# 지정가(ord_uv) = 조건단가(cond_uv) 보다 이 틱수만큼 아래.
|
||||
# 조건단가와 같게 두면 발동 후 그 가격 아래로는 안 팔려 미체결로 남는다.
|
||||
# 반대로 너무 벌리면 급락 시 헐값 매도가 되니 2틱.
|
||||
ORD_UV_GAP_TICKS = 2
|
||||
|
||||
# 계단 개수 상한. 고점이 오를 때마다 살아있는 레그를 전부 정정하므로 정정 콜이 계단 수에
|
||||
# 비례한다 (예약 3건 × 5계단 = 분당 15콜). 키움 rate limit 여유를 보고 5로 둔다.
|
||||
MAX_STEPS = 5
|
||||
|
||||
|
||||
# 혼동되는 글자(0/O, 1/I/l)를 뺀 영숫자 — 사람이 눈으로 읽고 옮겨 적는 id 라서.
|
||||
_ID_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(KST).isoformat(timespec='seconds')
|
||||
|
||||
|
||||
def _new_id(prefix: str) -> str:
|
||||
return prefix + ''.join(secrets.choice(_ID_CHARS) for _ in range(4))
|
||||
|
||||
|
||||
def floor_to_tick(price: int) -> int:
|
||||
"""호가단위로 내림. 매도 조건단가·지정가는 내림이 안전(더 빨리 발동/체결)."""
|
||||
t = tick_size(price)
|
||||
return price - (price % t)
|
||||
|
||||
|
||||
def compute_levels(peak: int, trail_pct: float, min_sell_price: Optional[int] = None) -> dict:
|
||||
"""고점·트레일 폭(·최저 매도가) → 조건단가(트리거)·지정가(체결가).
|
||||
|
||||
cond_uv = max(peak × (1 − pct/100), min_sell_price) 를 호가단위 내림
|
||||
ord_uv = cond_uv − ORD_UV_GAP_TICKS 틱
|
||||
|
||||
min_sell_price(최저 매도가) = 손절선의 하한. 트레일 폭을 넓게 잡아도 이 가격 아래로는
|
||||
손절선이 내려가지 않는다 — 초기 구간 손실 제한용. 고점이 올라 트레일 손절선이 이 값을
|
||||
추월하면 그 뒤로는 트레일이 지배한다(최저가는 자연히 무의미해짐).
|
||||
"""
|
||||
if peak <= 0:
|
||||
raise ValueError(f'peak must be > 0 (got {peak})')
|
||||
if not (MIN_TRAIL_PCT <= trail_pct <= MAX_TRAIL_PCT):
|
||||
raise ValueError(f'trail_pct out of range: {trail_pct}')
|
||||
# round(_, 6) 은 부동소수점 잡음만 걷어낸다 — 5500×(1−30/100) 이 3849.9999999999995 로
|
||||
# 나오는 탓에 int() 가 3849 로 깎고 호가단위 내림이 3845 까지 끌어내리던 버그가 있었다
|
||||
# (의도는 3850). 잡음을 없앤 뒤 내림하므로 "정확히 그 폭 아래" 성격은 그대로다.
|
||||
cond_uv = floor_to_tick(int(round(peak * (1 - trail_pct / 100), 6)))
|
||||
floor_applied = False
|
||||
if min_sell_price:
|
||||
if min_sell_price <= 0:
|
||||
raise ValueError(f'min_sell_price must be > 0 (got {min_sell_price})')
|
||||
floor_uv = floor_to_tick(int(min_sell_price))
|
||||
if floor_uv > cond_uv:
|
||||
cond_uv = floor_uv
|
||||
floor_applied = True
|
||||
ord_uv = floor_to_tick(cond_uv - ORD_UV_GAP_TICKS * tick_size(cond_uv))
|
||||
if cond_uv <= 0 or ord_uv <= 0:
|
||||
raise ValueError(f'computed level <= 0 (peak={peak}, pct={trail_pct})')
|
||||
return {'cond_uv': cond_uv, 'ord_uv': ord_uv, 'floor_applied': floor_applied}
|
||||
|
||||
|
||||
def normalize_steps(raw: list) -> list:
|
||||
"""입력한 계단 정의(누적 비중)를 내부 표현(추가 비중)으로 환산·검증한다.
|
||||
|
||||
입력 [{'pct': 10, 'cum': 20}, {'pct': 20, 'cum': 50}, {'pct': 30, 'cum': 100}]
|
||||
출력 [{'n': 1, 'pct': 10.0, 'cum': 20.0, 'weight': 20.0}, ... weight 30, 50]
|
||||
|
||||
cum(누적 비중)은 "이 하락률에 닿았을 때 최초 보유의 몇 %가 팔려 있어야 하는가"다.
|
||||
주문 수량은 계단마다의 추가분이므로 weight = cum − 직전 cum 으로 환산한다.
|
||||
|
||||
마지막 cum 이 100 미만이어도 허용한다(일부만 계단 청산하고 나머지는 계속 보유).
|
||||
남는 수량은 스톱이 걸리지 않으므로 호출측이 화면에 표시해 준다.
|
||||
"""
|
||||
if not raw:
|
||||
raise ValueError('계단이 비어 있음')
|
||||
if len(raw) > MAX_STEPS:
|
||||
raise ValueError(f'계단은 최대 {MAX_STEPS}개 (입력 {len(raw)}개)')
|
||||
steps = []
|
||||
prev_pct = 0.0
|
||||
prev_cum = 0.0
|
||||
for i, s in enumerate(raw, start=1):
|
||||
pct = float(s['pct'])
|
||||
cum = float(s['cum'])
|
||||
if not (MIN_TRAIL_PCT <= pct <= MAX_TRAIL_PCT):
|
||||
raise ValueError(f'{i}단계 하락률 {pct}% 는 {MIN_TRAIL_PCT}~{MAX_TRAIL_PCT}% 범위를 벗어남')
|
||||
if pct <= prev_pct:
|
||||
raise ValueError(f'{i}단계 하락률 {pct}% 가 앞 계단({prev_pct}%)보다 깊지 않음')
|
||||
if cum <= prev_cum:
|
||||
raise ValueError(f'{i}단계 누적 비중 {cum}% 가 앞 계단({prev_cum}%)보다 크지 않음')
|
||||
if cum > 100:
|
||||
raise ValueError(f'{i}단계 누적 비중 {cum}% 가 100% 를 넘음')
|
||||
steps.append({'n': i, 'pct': pct, 'cum': cum, 'weight': cum - prev_cum})
|
||||
prev_pct, prev_cum = pct, cum
|
||||
return steps
|
||||
|
||||
|
||||
def allocate_step_qty(total_qty: int, weights: list) -> list:
|
||||
"""계단별 추가 비중(%) → 정수 주수. 최대잔여법(Hare quota).
|
||||
|
||||
내림으로 나눈 뒤 남는 주를 소수부가 큰 계단부터 1주씩 배분한다.
|
||||
"내림 후 마지막 계단에 몰아주기" 보다 얕은 계단이 0주로 죽는 일이 적다
|
||||
(2주 · 20/30/50 → 최대잔여 [0,1,1] 2계단 생존 / 몰아주기 [0,0,2] 1계단).
|
||||
|
||||
수량이 적어 0주가 되는 계단은 그대로 0 을 반환한다 — 발주할 수 없으므로
|
||||
호출측이 걸러내고 "몇 계단으로 줄었는지" 를 사용자에게 알린다.
|
||||
"""
|
||||
if total_qty <= 0 or not weights:
|
||||
return [0] * len(weights)
|
||||
exact = [total_qty * w / 100.0 for w in weights]
|
||||
base = [int(x) for x in exact]
|
||||
remain = int(round(sum(exact))) - sum(base)
|
||||
order = sorted(range(len(weights)), key=lambda i: exact[i] - base[i], reverse=True)
|
||||
for i in order[:max(0, remain)]:
|
||||
base[i] += 1
|
||||
return base
|
||||
|
||||
|
||||
def compute_step_levels(peak: int, steps: list, min_sell_price: Optional[int] = None) -> list:
|
||||
"""고점 + 계단 정의 → 계단별 조건단가·지정가.
|
||||
|
||||
최저 매도가는 모든 계단에 같은 하한으로 걸린다. 그래서 초기에는 여러 계단이 같은
|
||||
가격으로 뭉칠 수 있다(전부 최저 매도가). 그래도 주문을 병합하지 않는 이유는, 고점이
|
||||
올라 트레일 손절선이 최저 매도가를 추월하면 각 계단이 제 하락률대로 다시 벌어지기
|
||||
때문이다 — 병합해 버리면 정정만으로는 다시 쪼갤 수 없다(신규 발주가 필요해진다).
|
||||
"""
|
||||
out = []
|
||||
for s in steps:
|
||||
lv = compute_levels(peak, s['pct'], min_sell_price)
|
||||
out.append({**s, 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv'],
|
||||
'floor_applied': lv['floor_applied']})
|
||||
return out
|
||||
|
||||
|
||||
def next_step_levels(res: dict, cur_price: int) -> Optional[dict]:
|
||||
"""현재가가 고점을 넘었으면 새 고점과 상향 대상 레그를 반환 (순수 함수 — 단위테스트 대상).
|
||||
|
||||
반환 {'peak': 새 고점, 'steps': [{'n', 'cond_uv', 'ord_uv'}, ...]}.
|
||||
고점이 안 올랐으면 None.
|
||||
|
||||
steps 가 빈 리스트일 수 있다 — 고점은 올랐지만 호가단위 내림이나 최저 매도가 지배로
|
||||
조건단가가 하나도 안 올라간 경우다. 이때 호출측은 정정 API 없이 고점만 갱신한다
|
||||
(불필요한 콜 0, 화면의 고점 표시는 정확하게 유지).
|
||||
"""
|
||||
if cur_price <= 0 or cur_price <= res['peak']:
|
||||
return None
|
||||
ups = []
|
||||
for s in res.get('steps') or []:
|
||||
lv = compute_levels(cur_price, s['pct'], res.get('min_sell_price'))
|
||||
if lv['cond_uv'] > s['cond_uv']:
|
||||
ups.append({'n': s['n'], 'cond_uv': lv['cond_uv'], 'ord_uv': lv['ord_uv']})
|
||||
return {'peak': cur_price, 'steps': ups}
|
||||
|
||||
|
||||
class _FileLock:
|
||||
def __init__(self, path: Path):
|
||||
self.path = path
|
||||
self._fp = None
|
||||
|
||||
def __enter__(self):
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._fp = open(self.path, 'w')
|
||||
fcntl.flock(self._fp, fcntl.LOCK_EX)
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
try:
|
||||
fcntl.flock(self._fp, fcntl.LOCK_UN)
|
||||
finally:
|
||||
self._fp.close()
|
||||
|
||||
|
||||
_LOCK_FILE = STATE_FILE.with_suffix(STATE_FILE.suffix + '.lock')
|
||||
|
||||
|
||||
def _read_doc() -> dict:
|
||||
"""상태파일 전체. `reservations`(키움에 주문이 나가 있는 예약) 와
|
||||
`arms`(발동가 도달을 기다리는 대기 예약) 두 갈래가 한 파일에 들어 있다.
|
||||
|
||||
한 파일에 둔 이유는 발동이 "arm 제거 + 예약 등록" 이라는 하나의 원자적 전이이기
|
||||
때문이다. 파일이 둘이면 락을 중첩해야 하고, 중간에 죽으면 둘 다 존재하거나 둘 다
|
||||
사라진다.
|
||||
"""
|
||||
empty = {'reservations': [], 'arms': [], 'recent': []}
|
||||
if not STATE_FILE.exists():
|
||||
return empty
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding='utf-8'))
|
||||
except (OSError, ValueError):
|
||||
return empty
|
||||
if not isinstance(data, dict):
|
||||
return empty
|
||||
return {'reservations': data.get('reservations') or [],
|
||||
'arms': data.get('arms') or [],
|
||||
'recent': data.get('recent') or []}
|
||||
|
||||
|
||||
def _read() -> list:
|
||||
return _read_doc()['reservations']
|
||||
|
||||
|
||||
def _read_arms() -> list:
|
||||
return _read_doc()['arms']
|
||||
|
||||
|
||||
def _read_recent() -> list:
|
||||
return _read_doc()['recent']
|
||||
|
||||
|
||||
def _write(reservations: Optional[list] = None, arms: Optional[list] = None,
|
||||
recent: Optional[list] = None) -> None:
|
||||
"""None 인 쪽은 디스크의 현재 값을 유지한다.
|
||||
|
||||
⚠️ 세 갈래가 한 파일에 있으므로 한쪽만 쓰는 호출이 다른 쪽을 지우면 안 된다.
|
||||
반드시 _FileLock 안에서 부를 것 — 여기서 다시 읽어 병합한다.
|
||||
"""
|
||||
doc = _read_doc()
|
||||
if reservations is not None:
|
||||
doc['reservations'] = reservations
|
||||
if arms is not None:
|
||||
doc['arms'] = arms
|
||||
if recent is not None:
|
||||
doc['recent'] = recent
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix(STATE_FILE.suffix + '.tmp')
|
||||
tmp.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||
os.replace(tmp, STATE_FILE)
|
||||
|
||||
|
||||
def list_active() -> list:
|
||||
"""등록된 예약 전체 (락 없이 읽기 — 감시 루프의 스냅샷용)."""
|
||||
return _read()
|
||||
|
||||
|
||||
def get(res_id: str) -> Optional[dict]:
|
||||
for r in _read():
|
||||
if r['id'] == res_id:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def find_by_symbol(account: str, symbol: str) -> Optional[dict]:
|
||||
"""같은 계좌·종목의 기존 예약. 중복 등록 차단용."""
|
||||
for r in _read():
|
||||
if r['account'] == account and r['symbol'] == symbol:
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def _build_reservation(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||||
peak: int, steps: list, routing_suffix: str,
|
||||
card_id: Optional[str], min_sell_price: Optional[int]) -> dict:
|
||||
"""접수된 레그 목록 → 예약 dict (파일 접근 없는 순수 조립).
|
||||
|
||||
register_steps(등록 즉시 트레일링)와 promote_arm(대기 예약 발동)이 공유한다.
|
||||
"""
|
||||
if not steps:
|
||||
raise ValueError('등록할 계단이 없음 — 접수 성공한 레그가 하나도 없다')
|
||||
for s in steps:
|
||||
if not s.get('ord_no'):
|
||||
raise ValueError(f'{s.get("n")}단계 ord_no 없음 — 접수 확인된 주문만 등록')
|
||||
return {
|
||||
'id': _new_id('TRL-'),
|
||||
'account': account,
|
||||
'symbol': symbol,
|
||||
'symbol_name': symbol_name,
|
||||
'qty': total_qty,
|
||||
'min_sell_price': min_sell_price,
|
||||
'peak': peak,
|
||||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight'],
|
||||
'qty': s['qty'], 'ord_no': s['ord_no'],
|
||||
'cond_uv': s['cond_uv'], 'ord_uv': s['ord_uv'],
|
||||
'entry_cond_uv': s['cond_uv'], 'modify_count': 0} for s in steps],
|
||||
# 등록 시점 계단 **정의**(가격·주수 없음). steps 는 레그가 체결·소멸할 때마다 줄어들고
|
||||
# 마지막 레그가 빠지면 비어버려서, 재등록용 원본이 여기 없으면 계단을 복원할 수 없다.
|
||||
'entry_steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight']}
|
||||
for s in steps],
|
||||
'routing_suffix': routing_suffix,
|
||||
'card_id': card_id,
|
||||
'created_at': _now_iso(),
|
||||
'updated_at': _now_iso(),
|
||||
'entry_peak': peak,
|
||||
}
|
||||
|
||||
|
||||
def register_steps(account: str, symbol: str, symbol_name: str, total_qty: int,
|
||||
peak: int, steps: list, routing_suffix: str = '',
|
||||
card_id: Optional[str] = None,
|
||||
min_sell_price: Optional[int] = None) -> dict:
|
||||
"""키움에 계단별 스톱주문이 접수된 직후 호출 — 레그 목록을 예약 1건으로 등록.
|
||||
|
||||
steps 각 항목은 접수 결과까지 채워져 있어야 한다:
|
||||
{'n', 'pct', 'cum', 'weight', 'qty', 'ord_no', 'cond_uv', 'ord_uv'}
|
||||
|
||||
접수에 실패한 레그는 호출측이 빼고 넘긴다 — 여기 등록된 것은 전부 키움에 살아있는
|
||||
주문이어야 감시 루프의 생존 판정이 성립한다.
|
||||
|
||||
초기 고점(peak)은 등록 시점 현재가다. 과거 고점(52주·매수후) 기준 옵션은 손절선이
|
||||
현재가 위로 올라가 즉시 발동하는 구조라 2026-07-30 제거됐다.
|
||||
"""
|
||||
res = _build_reservation(account, symbol, symbol_name, total_qty, peak, steps,
|
||||
routing_suffix, card_id, min_sell_price)
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
reservations.append(res)
|
||||
_write(reservations)
|
||||
return res
|
||||
|
||||
|
||||
def commit_peak(res_id: str, peak: int) -> Optional[dict]:
|
||||
"""고점만 갱신 (정정할 레그가 없었던 경우). 화면의 고점 표시를 정확하게 유지한다."""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for r in reservations:
|
||||
if r['id'] == res_id:
|
||||
r['peak'] = peak
|
||||
r['updated_at'] = _now_iso()
|
||||
_write(reservations)
|
||||
return r
|
||||
return None
|
||||
|
||||
|
||||
def commit_step_modify(res_id: str, step_n: int, new_ord_no: str, peak: int,
|
||||
cond_uv: int, ord_uv: int) -> Optional[dict]:
|
||||
"""레그 정정 성공 후 상태 갱신. new_ord_no 는 kt10002 응답의 신규 주문번호.
|
||||
|
||||
⚠️ ord_no 갱신이 이 함수의 핵심 — 안 하면 그 레그의 다음 정정이 전부 실패한다.
|
||||
고점은 예약 단위라 레그마다 같은 값으로 덮어써도 무해하다.
|
||||
"""
|
||||
if not new_ord_no:
|
||||
raise ValueError('new_ord_no required')
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for r in reservations:
|
||||
if r['id'] != res_id:
|
||||
continue
|
||||
for s in r.get('steps') or []:
|
||||
if s['n'] != step_n:
|
||||
continue
|
||||
s['ord_no'] = new_ord_no
|
||||
s['cond_uv'] = cond_uv
|
||||
s['ord_uv'] = ord_uv
|
||||
s['modify_count'] = s.get('modify_count', 0) + 1
|
||||
r['peak'] = peak
|
||||
r['updated_at'] = _now_iso()
|
||||
_write(reservations)
|
||||
return r
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def remove_step(res_id: str, step_n: int, reason: str = '') -> Optional[dict]:
|
||||
"""레그 하나를 예약에서 제거 (체결·취소·소멸). 마지막 레그면 예약 자체를 지운다.
|
||||
|
||||
반환 {'step': 제거된 레그, 'reservation': 예약 스냅샷, 'remaining': 남은 레그 수,
|
||||
'reservation_removed': bool} — 알림 메시지 조립용.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for i, r in enumerate(reservations):
|
||||
if r['id'] != res_id:
|
||||
continue
|
||||
steps = r.get('steps') or []
|
||||
hit = next((s for s in steps if s['n'] == step_n), None)
|
||||
if hit is None:
|
||||
return None
|
||||
steps.remove(hit)
|
||||
hit['removed_reason'] = reason
|
||||
snapshot = json.loads(json.dumps(r))
|
||||
if not steps:
|
||||
reservations.pop(i)
|
||||
_write(reservations)
|
||||
return {'step': hit, 'reservation': snapshot, 'remaining': 0,
|
||||
'reservation_removed': True}
|
||||
r['updated_at'] = _now_iso()
|
||||
_write(reservations)
|
||||
return {'step': hit, 'reservation': snapshot, 'remaining': len(steps),
|
||||
'reservation_removed': False}
|
||||
return None
|
||||
|
||||
|
||||
def should_notify_failure(res_id: str, now_ts: float, cooldown_sec: int) -> bool:
|
||||
"""정정 실패 알림을 보낼지. 보낸다고 판단하면 그 시점을 기록한다 (락 안에서 원자적).
|
||||
|
||||
NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 실패가 반복된다 —
|
||||
쿨다운 없이 알리면 20:00까지 매분 텔레그램이 쏟아진다.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for r in reservations:
|
||||
if r['id'] != res_id:
|
||||
continue
|
||||
last = r.get('last_fail_notice_ts') or 0
|
||||
if now_ts - last < cooldown_sec:
|
||||
return False
|
||||
r['last_fail_notice_ts'] = now_ts
|
||||
_write(reservations)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def remove(res_id: str, reason: str = '') -> Optional[dict]:
|
||||
"""예약 종료 (체결·취소·소멸). 반환값은 제거된 예약 — 알림 메시지용."""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
reservations = _read()
|
||||
for i, r in enumerate(reservations):
|
||||
if r['id'] == res_id:
|
||||
gone = reservations.pop(i)
|
||||
_write(reservations)
|
||||
gone['removed_reason'] = reason
|
||||
return gone
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 대기 예약 (arm) — 발동가에 닿으면 그때 트레일링을 등록한다
|
||||
#
|
||||
# 트레일링 스톱은 등록 즉시 계단별 스톱주문이 키움에 나가고 그 순간의 현재가가 고점이
|
||||
# 된다. 아직 오르지 않은 종목엔 그게 불리해서, 발동가를 미리 정해두고 현재가가 거기에
|
||||
# 올라와 닿는 순간 그때의 현재가를 고점으로 트레일링을 시작하는 갈래를 둔다.
|
||||
#
|
||||
# 대기 중인 예약은 키움이 아니라 이 파일에만 있다 — 거래소가 지워주지 않으므로 **우리가**
|
||||
# 스톱주문과 같은 수명(등록일 당일)을 강제한다(is_arm_expired). 2026-08-25 관리자님 결정.
|
||||
# 대신 감시 프로세스가 죽어 있으면 발동하지 않는다 — 이건 스톱주문과 정반대 성질이다.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_arms() -> list:
|
||||
"""대기 예약 전체 (락 없이 읽기 — 감시 루프의 스냅샷용)."""
|
||||
return _read_arms()
|
||||
|
||||
|
||||
def get_arm(arm_id: str) -> Optional[dict]:
|
||||
for a in _read_arms():
|
||||
if a['id'] == arm_id:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def find_arm_by_symbol(account: str, symbol: str) -> Optional[dict]:
|
||||
"""같은 계좌·종목의 대기 예약. 중복 등록 차단용."""
|
||||
for a in _read_arms():
|
||||
if a['account'] == account and a['symbol'] == symbol:
|
||||
return a
|
||||
return None
|
||||
|
||||
|
||||
def register_arm(account: str, symbol: str, symbol_name: str, qty: int,
|
||||
trigger_price: int, steps: list,
|
||||
min_sell_price: Optional[int] = None,
|
||||
card_id: Optional[str] = None) -> dict:
|
||||
"""PIN 승인 직후 호출 — 발동가 도달을 기다리는 예약 1건 등록. 키움 주문은 아직 없다.
|
||||
|
||||
steps 는 normalize_steps 결과, 즉 **정의만**(n/pct/cum/weight)이다. 조건단가·지정가·
|
||||
계단별 주수는 저장하지 않는다 — 갭 상승으로 발동가를 훌쩍 넘겨 열릴 수 있고 그 사이
|
||||
보유수량이 줄었을 수도 있어서, 발동 시점의 현재가와 매도가능 수량으로 다시 계산해야
|
||||
실제 시세와 맞는다.
|
||||
|
||||
routing_suffix 도 저장하지 않는다. 당일 안에서도 등록 시점 세션(정규장)과 발동 시점
|
||||
세션(NXT 애프터)이 다를 수 있고, 옛 suffix 로 발주하면 거부된다.
|
||||
"""
|
||||
if not steps:
|
||||
raise ValueError('계단이 비어 있음')
|
||||
if trigger_price <= 0:
|
||||
raise ValueError('발동가가 0 이하')
|
||||
arm = {
|
||||
'id': _new_id('ARM-'),
|
||||
'account': account,
|
||||
'symbol': symbol,
|
||||
'symbol_name': symbol_name,
|
||||
'qty': qty,
|
||||
'trigger_price': trigger_price,
|
||||
'min_sell_price': min_sell_price,
|
||||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s['weight']}
|
||||
for s in steps],
|
||||
'card_id': card_id,
|
||||
'created_at': _now_iso(),
|
||||
'updated_at': _now_iso(),
|
||||
'firing_at': None,
|
||||
}
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
arms.append(arm)
|
||||
_write(arms=arms)
|
||||
return arm
|
||||
|
||||
|
||||
def remove_arm(arm_id: str, reason: str = '') -> Optional[dict]:
|
||||
"""대기 예약 제거 (취소·수량 소멸·계단 소멸). 반환값은 제거된 arm — 알림 메시지용."""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for i, a in enumerate(arms):
|
||||
if a['id'] == arm_id:
|
||||
gone = arms.pop(i)
|
||||
_write(arms=arms)
|
||||
gone['removed_reason'] = reason
|
||||
return gone
|
||||
return None
|
||||
|
||||
|
||||
def mark_arm_firing(arm_id: str) -> bool:
|
||||
"""발주 직전 마킹. 이미 마킹돼 있으면 False (재발동 차단).
|
||||
|
||||
키움 발주가 성공한 뒤 promote_arm 전에 프로세스가 죽으면 다음 사이클이 같은 종목을
|
||||
또 판다. 창은 1초 남짓이지만 결과가 이중 매도라, 발주를 시작했다는 사실을 먼저
|
||||
디스크에 남긴다.
|
||||
|
||||
⚠️ 마킹이 남아있는 arm 은 **자동으로 재시도하지 않는다** — 주문이 나갔는지 알 수 없는
|
||||
상태에서 다시 내는 게 바로 이중 매도다. 감시 루프는 알림만 보내고, 관리자님이 실제
|
||||
미체결을 확인한 뒤 자산웹에서 취소한다.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for a in arms:
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
if a.get('firing_at'):
|
||||
return False
|
||||
a['firing_at'] = _now_iso()
|
||||
a['updated_at'] = _now_iso()
|
||||
_write(arms=arms)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 최근 종료 (recent) — 재등록 버튼용 단기 캐시
|
||||
#
|
||||
# 트레일링 스톱은 장 마감에 거래소가 지우고, 대기 예약은 등록일 당일로 우리가 지운다. 둘 다
|
||||
# 사라지는 순간 계좌·수량·계단이 함께 없어져서, 다시 걸려면 처음부터 입력해야 했다.
|
||||
# 종료 시점의 설정을 짧게 보관해 자산웹이 "이전 설정 그대로 채워진 거래 모달" 을 열 수 있게 한다.
|
||||
#
|
||||
# ⚠️ 이건 **UI 편의용 단기 캐시이지 감사 기록이 아니다.** 감사 원장은 state/order_log.jsonl 이고
|
||||
# 그쪽은 무한 append 로 그대로 둔다. 여기는 링버퍼라 오래된 건 지워진다.
|
||||
# ⚠️ 재등록은 **사람이 버튼을 누르고 PIN 을 다시 거친다** — 감시 루프가 스스로 되살리는 경로는
|
||||
# 없다. "자동 재등록 안 함"(2026-07-30 결정)은 그대로 유효하다.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
RECENT_MAX = 20
|
||||
RECENT_DAYS = 7
|
||||
|
||||
|
||||
def list_recent() -> list:
|
||||
"""최근 종료 목록 (최신이 앞). 락 없이 읽기 — 웹 렌더용."""
|
||||
return _read_recent()
|
||||
|
||||
|
||||
def get_recent(key: str) -> Optional[dict]:
|
||||
for e in _read_recent():
|
||||
if e.get('key') == key:
|
||||
return e
|
||||
return None
|
||||
|
||||
|
||||
def push_recent(entry: dict) -> dict:
|
||||
"""종료된 예약 1건을 최근 목록 맨 앞에 넣는다 (링버퍼).
|
||||
|
||||
⚠️ 제거(remove_step/remove_arm) 와 **같은 락에 묶지 않는다.** 레그 소멸은 레그마다 제거가
|
||||
끝난 **뒤에야** "예약이 죽었다" 를 알 수 있어 애초에 한 락에 못 넣기 때문이다. 그 사이에
|
||||
죽으면 항목 하나를 잃는데, 순서를 뒤집으면(기록 먼저) 같은 자리에서 중복이 남는다 —
|
||||
편의 기능이라 유실이 중복보다 낫다.
|
||||
"""
|
||||
entry = dict(entry)
|
||||
entry.setdefault('ended_at', _now_iso())
|
||||
entry['key'] = _new_id('RC-')
|
||||
cutoff = (datetime.now(KST) - timedelta(days=RECENT_DAYS)).isoformat(timespec='seconds')
|
||||
with _FileLock(_LOCK_FILE):
|
||||
recent = [e for e in _read_recent() if (e.get('ended_at') or '') >= cutoff]
|
||||
recent.insert(0, entry)
|
||||
_write(recent=recent[:RECENT_MAX])
|
||||
return entry
|
||||
|
||||
|
||||
def make_recent_entry(kind: str, reason: str, src: dict, steps: list) -> dict:
|
||||
"""예약/대기예약 스냅샷 → 재등록에 필요한 것만 뽑은 항목.
|
||||
|
||||
steps 는 정의만(n/pct/cum/weight) — 가격·주수는 재등록 시점에 다시 계산된다.
|
||||
"""
|
||||
return {
|
||||
'kind': kind, # 'trailing' | 'arm' → 재등록 order_type 을 가른다
|
||||
'reason': reason,
|
||||
'account': src['account'],
|
||||
'symbol': src['symbol'],
|
||||
'symbol_name': src.get('symbol_name') or src['symbol'],
|
||||
'qty': src.get('qty') or 0,
|
||||
'steps': [{'n': s['n'], 'pct': s['pct'], 'cum': s['cum'], 'weight': s.get('weight')}
|
||||
for s in steps],
|
||||
'min_sell_price': src.get('min_sell_price'),
|
||||
'trigger_price': src.get('trigger_price'), # arm 만. trailing 은 None
|
||||
}
|
||||
|
||||
|
||||
def is_arm_expired(arm: dict, now: Optional[datetime] = None) -> bool:
|
||||
"""등록일의 거래 세션이 끝났으면 만료 (2026-08-25 관리자님 결정으로 무기한 → 당일).
|
||||
|
||||
트레일링 스톱주문이 장 마감에 소멸하는 것과 **같은 수명**을 갖게 한 것이다. 대기 예약은
|
||||
키움이 아니라 우리 파일에만 있어 거래소가 지워주지 않으므로, 우리가 지워야 한다.
|
||||
|
||||
판정은 두 갈래:
|
||||
· 등록일이 오늘보다 이전 → 만료 (밤새 넘긴 것·주말 넘긴 것)
|
||||
· 등록일이 오늘이고 이미 20:00 지남 → 만료
|
||||
|
||||
⚠️ 감시 루프는 08:00~19:59 에만 도므로 **20:00 직후에 지워지지는 않는다.** 실제 정리는
|
||||
다음 거래일 첫 사이클이다. 그 사이(장외)에 자산웹을 열면 만료 상태로 표시된다 —
|
||||
화면이 거짓말하지 않게 웹도 같은 판정을 쓴다.
|
||||
"""
|
||||
now = now or datetime.now(KST)
|
||||
created = (arm.get('created_at') or '')[:10]
|
||||
if not created:
|
||||
return False # 날짜를 모르면 함부로 지우지 않는다
|
||||
today = now.strftime('%Y-%m-%d')
|
||||
if created < today:
|
||||
return True
|
||||
return created == today and after_trading_day(now)
|
||||
|
||||
|
||||
def record_arm_leg(arm_id: str, leg: dict) -> bool:
|
||||
"""발동 중 레그 하나가 접수될 때마다 즉시 기록 (write-ahead).
|
||||
|
||||
firing_at 만으로는 중간에 죽었을 때 "몇 단이 실제로 나갔는지" 를 알 수 없다. 키움엔 우리
|
||||
주문을 식별하는 client order id 가 없어 미체결 목록만으로는 관리자님이 직접 낸 스톱주문과
|
||||
구분되지 않는다 — 그래서 주문번호를 우리가 적어둔다.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for a in arms:
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
a.setdefault('fired_legs', []).append(
|
||||
{'n': leg['n'], 'qty': leg['qty'], 'cond_uv': leg['cond_uv'],
|
||||
'ord_uv': leg['ord_uv'], 'ord_no': leg.get('ord_no', '')})
|
||||
a['updated_at'] = _now_iso()
|
||||
_write(arms=arms)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def plan_arm_fire(arm: dict, cur_price: int, sellable_qty: int) -> dict:
|
||||
"""대기 예약 + 현재가 + 매도가능 수량 → 발주 계획 (순수 함수 — 단위테스트 대상).
|
||||
|
||||
반환 {'action', 'reason', 'peak', 'qty_used', 'reduced', 'legs'}
|
||||
fire : legs 를 그대로 발주한다
|
||||
skip : 아직 아니다 (미도달·시세 없음) — 아무것도 하지 않는다
|
||||
drop : 발동할 수 없다 (매도가능 0주·수량 부족) → 예약 삭제 + 알림
|
||||
|
||||
고점(peak)은 발동 시점 현재가다. 발동가가 아니라 현재가를 쓰는 이유는 갭 상승으로 발동가를
|
||||
훌쩍 넘겨 열릴 수 있어서다 — 현재가 >= 발동가 이므로 손절선은 승인 시점 미리보기보다 항상
|
||||
같거나 높다(관리자님에게 불리해지지 않는 방향).
|
||||
|
||||
수량은 min(예약 수량, 매도가능)이다. 대기 중 관리자님이 직접 팔았으면 그만큼 줄여 재배분한다
|
||||
— 대기 예약이 보유분을 잠그지 않기로 한 결정의 뒷수습이 여기다.
|
||||
"""
|
||||
def _out(action, reason):
|
||||
return {'action': action, 'reason': reason, 'peak': cur_price,
|
||||
'qty_used': 0, 'reduced': False, 'legs': []}
|
||||
|
||||
if cur_price <= 0:
|
||||
return _out('skip', 'no_price')
|
||||
if cur_price < arm['trigger_price']:
|
||||
return _out('skip', 'not_reached')
|
||||
if sellable_qty <= 0:
|
||||
return _out('drop', 'no_sellable')
|
||||
|
||||
qty_used = min(arm['qty'], sellable_qty)
|
||||
steps = arm.get('steps') or []
|
||||
leveled = compute_step_levels(cur_price, steps, arm.get('min_sell_price'))
|
||||
qtys = allocate_step_qty(qty_used, [s['weight'] for s in steps])
|
||||
# 0주가 된 계단은 발주할 수 없으니 떨어낸다. 단계 번호(n)는 유지해 몇 단계가 빠졌는지 보인다.
|
||||
legs = [dict(s, qty=q) for s, q in zip(leveled, qtys) if q > 0]
|
||||
if not legs:
|
||||
return _out('drop', 'qty_too_small')
|
||||
# 실주문 직전 마지막 안전망. propose 가 발동가 기준으로 이미 걸렀고 현재가 >= 발동가,
|
||||
# 최저 매도가는 승인 후 불변이라 정상 경로에선 도달하지 않는다. 그래도 남기는 이유는
|
||||
# 여기를 지나면 되돌릴 수 없는 매도 주문이 나가기 때문이다.
|
||||
if legs[0]['cond_uv'] >= cur_price:
|
||||
return _out('skip', 'immediate')
|
||||
return {'action': 'fire', 'reason': '', 'peak': cur_price, 'qty_used': qty_used,
|
||||
'reduced': qty_used < arm['qty'], 'legs': legs}
|
||||
|
||||
|
||||
def promote_arm(arm_id: str, peak: int, steps: list,
|
||||
routing_suffix: str = '') -> Optional[dict]:
|
||||
"""발동 완료 — 대기 예약을 지우고 같은 내용의 트레일링 예약을 등록 (한 락 안에서 원자적).
|
||||
|
||||
steps 는 접수 결과까지 채워진 레그 목록(register_steps 와 같은 형식).
|
||||
둘을 따로 하면 중간에 죽었을 때 arm 과 예약이 동시에 존재하거나 둘 다 사라진다.
|
||||
|
||||
반환 {'arm': 제거된 arm, 'reservation': 새 예약} — 알림 메시지 조립용.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
doc = _read_doc()
|
||||
arms = doc['arms']
|
||||
for i, a in enumerate(arms):
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
res = _build_reservation(
|
||||
a['account'], a['symbol'], a['symbol_name'],
|
||||
sum(s['qty'] for s in steps), peak, steps,
|
||||
routing_suffix, a.get('card_id'), a.get('min_sell_price'))
|
||||
arms.pop(i)
|
||||
reservations = doc['reservations']
|
||||
reservations.append(res)
|
||||
_write(reservations=reservations, arms=arms)
|
||||
return {'arm': a, 'reservation': res}
|
||||
return None
|
||||
|
||||
|
||||
def should_notify_arm_failure(arm_id: str, now_ts: float, cooldown_sec: int) -> bool:
|
||||
"""발동 실패 알림을 보낼지. 보낸다고 판단하면 그 시점을 기록한다 (락 안에서 원자적).
|
||||
|
||||
firing_at 이 걸린 채 남은 arm 이나 사이드카 ON 상태는 매 사이클 같은 판정이 반복된다 —
|
||||
쿨다운 없이 알리면 19:59까지 매분 텔레그램이 쏟아진다. should_notify_failure 와 같은 패턴.
|
||||
"""
|
||||
with _FileLock(_LOCK_FILE):
|
||||
arms = _read_arms()
|
||||
for a in arms:
|
||||
if a['id'] != arm_id:
|
||||
continue
|
||||
last = a.get('last_fail_notice_ts') or 0
|
||||
if now_ts - last < cooldown_sec:
|
||||
return False
|
||||
a['last_fail_notice_ts'] = now_ts
|
||||
_write(arms=arms)
|
||||
return True
|
||||
return False
|
||||
@@ -345,6 +345,7 @@ class RealtimeHub:
|
||||
'open': _abs_int(vals.get('16')), # 시가
|
||||
'high': _abs_int(vals.get('17')), # 고가
|
||||
'low': _abs_int(vals.get('18')), # 저가
|
||||
'volume': _abs_int(vals.get('13')), # 누적거래량 (RVOL 배지용)
|
||||
'exchange': (vals.get('9081') or '').strip(),
|
||||
'ts': datetime.now(KST).isoformat(),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""매월 1일 04:30 — 본인 계좌 잔액을 골디 inbox에 envelope로 떨어뜨린다.
|
||||
"""매월 1일 04:30 — 본인+가희 계좌 잔액을 골디 inbox에 envelope로 떨어뜨린다.
|
||||
|
||||
가희 계좌는 제외 (label prefix '가희_'). 본인 계좌별 평가액(kt00018)·예수금(kt00001)·총자산을 집계.
|
||||
소유자별(본인 self / 가희 gahee, label prefix '가희_'로 판별)로 평가액(kt00018)·예수금(kt00001)·
|
||||
총자산을 집계해 payload.by_owner에 담는다. 골디는 self→증권(효원), gahee→증권(가희)로 reconcile.
|
||||
LLM 불필요 — launchd로 실행. 실패 시 레이 텔레그램으로 자가 알림.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -26,7 +27,7 @@ INBOX_DIR = Path('/Users/snowoyh/.openclaw/agents/budget/inbox/incoming')
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
TELEGRAM_ACCOUNT = 'stock'
|
||||
TOPIC = 'securities_balance'
|
||||
SCHEMA_VERSION = 1
|
||||
SCHEMA_VERSION = 2
|
||||
GAHEE_PREFIX = '가희_'
|
||||
|
||||
|
||||
@@ -60,13 +61,12 @@ def send_telegram(text: str) -> bool:
|
||||
return ok
|
||||
|
||||
|
||||
def collect_owner_accounts() -> list[dict]:
|
||||
def collect_accounts() -> list[dict]:
|
||||
accounts = kw.list_accounts()
|
||||
out = []
|
||||
for a in accounts:
|
||||
label = a['label']
|
||||
if label.startswith(GAHEE_PREFIX):
|
||||
continue
|
||||
owner = 'gahee' if label.startswith(GAHEE_PREFIX) else 'self'
|
||||
balance = kw.get_balance(label)
|
||||
positions = kw.get_positions(label)
|
||||
eval_amount = sum(p.get('evlt_amt', 0) for p in positions)
|
||||
@@ -75,6 +75,7 @@ def collect_owner_accounts() -> list[dict]:
|
||||
deposit = balance.get('d2_entra', 0)
|
||||
out.append({
|
||||
'label': label,
|
||||
'owner': owner,
|
||||
'account_no': a.get('account_no', ''),
|
||||
'deposit': deposit,
|
||||
'eval_amount': eval_amount,
|
||||
@@ -86,6 +87,12 @@ def collect_owner_accounts() -> list[dict]:
|
||||
|
||||
def build_message(accounts: list[dict]) -> dict:
|
||||
now = datetime.now(KST)
|
||||
by_owner: dict[str, dict] = {}
|
||||
for a in accounts:
|
||||
o = by_owner.setdefault(a['owner'], {'deposit': 0, 'eval_amount': 0, 'total': 0})
|
||||
o['deposit'] += a['deposit']
|
||||
o['eval_amount'] += a['eval_amount']
|
||||
o['total'] += a['total']
|
||||
return {
|
||||
'message_id': str(uuid.uuid4()),
|
||||
'from': 'stock',
|
||||
@@ -95,8 +102,9 @@ def build_message(accounts: list[dict]) -> dict:
|
||||
'schema_version': SCHEMA_VERSION,
|
||||
'payload': {
|
||||
'as_of': now.strftime('%Y-%m-%d'),
|
||||
'owner_scope': 'self_only',
|
||||
'owner_scope': 'self_and_gahee',
|
||||
'accounts': accounts,
|
||||
'by_owner': by_owner,
|
||||
'totals': {
|
||||
'deposit': sum(a['deposit'] for a in accounts),
|
||||
'eval_amount': sum(a['eval_amount'] for a in accounts),
|
||||
@@ -117,9 +125,9 @@ def write_message(msg: dict) -> Path:
|
||||
|
||||
def main() -> int:
|
||||
try:
|
||||
accounts = collect_owner_accounts()
|
||||
accounts = collect_accounts()
|
||||
if not accounts:
|
||||
raise RuntimeError('본인 계좌 0개 — 자격증명 또는 prefix 필터 점검 필요')
|
||||
raise RuntimeError('계좌 0개 — 자격증명 점검 필요')
|
||||
msg = build_message(accounts)
|
||||
path = write_message(msg)
|
||||
total = msg['payload']['totals']['total']
|
||||
|
||||
@@ -18,6 +18,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -47,7 +48,7 @@ REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
PROMPT_FILE = WORKSPACE / 'prompts' / 'stock_analysis.md'
|
||||
ALIAS_FILE = WORKSPACE / 'prompts' / 'name_aliases.json'
|
||||
|
||||
LLM_MODEL = 'openai-codex/gpt-5.5'
|
||||
LLM_MODEL = 'openai/gpt-5.5'
|
||||
LLM_TIMEOUT_SEC = 120
|
||||
|
||||
|
||||
@@ -61,6 +62,59 @@ def now_kst() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def session_vol_frac(candles_asc: list[dict]) -> float | None:
|
||||
"""마지막 봉이 오늘·정규장(09:00~15:30) 진행형이면 세션경과 비율(0.15~1.0), 아니면 None.
|
||||
장중 누적 거래량을 하루치로 환산(RVOL·회전율)하는 분모. 휴장/주말은 오늘 봉이 없어 자연 배제.
|
||||
NXT(15:30~) 시간대는 KRX 마감 후라 완성봉 취급(None)."""
|
||||
if not candles_asc:
|
||||
return None
|
||||
now = now_kst()
|
||||
if candles_asc[-1].get('date') != now.strftime('%Y%m%d'):
|
||||
return None
|
||||
hm = now.hour * 60 + now.minute
|
||||
if not (9 * 60 <= hm < 15 * 60 + 30):
|
||||
return None
|
||||
return min(1.0, max(0.15, (hm - 9 * 60) / 390.0)) # 09:00~15:30 = 390분
|
||||
|
||||
|
||||
def turnover_today(candles_asc: list[dict], vol_time_frac: float | None = None) -> tuple[float | None, bool]:
|
||||
"""오늘 회전율(%)과 장중환산 여부. 마지막 봉 turnover_rate 우선, 없으면 최근 완성봉에서
|
||||
상장주식수 역산 → 오늘 거래량으로 산출. vol_time_frac 있으면 오늘 누적을 하루치로 투영('예상').
|
||||
데이터 없으면 (None, scaled)."""
|
||||
if not candles_asc:
|
||||
return None, False
|
||||
scaled = bool(vol_time_frac and 0 < vol_time_frac < 1.0)
|
||||
vols = [c.get('volume') or 0 for c in candles_asc]
|
||||
today_v = vols[-1] if vols else 0
|
||||
proj_v = (today_v / vol_time_frac) if scaled and today_v else today_v
|
||||
last_c = candles_asc[-1]
|
||||
if last_c.get('turnover_rate') and not scaled:
|
||||
return last_c['turnover_rate'], scaled
|
||||
for c in reversed(candles_asc[:-1]):
|
||||
tr, cv = c.get('turnover_rate'), c.get('volume')
|
||||
if tr and cv:
|
||||
listed = cv / (tr / 100.0)
|
||||
return (proj_v / listed * 100.0, scaled) if listed > 0 else (None, scaled)
|
||||
return None, scaled
|
||||
|
||||
|
||||
def rvol_today(candles_asc: list[dict], vol_time_frac: float | None = None) -> tuple[float | None, bool]:
|
||||
"""상대거래량(RVOL) = 오늘 거래량 ÷ 직전 20거래일 평균, 장중환산 여부.
|
||||
vol_time_frac 있으면 오늘 누적을 하루치로 투영('예상'). 데이터<21봉이면 (None, scaled)."""
|
||||
if not candles_asc:
|
||||
return None, False
|
||||
scaled = bool(vol_time_frac and 0 < vol_time_frac < 1.0)
|
||||
vols = [c.get('volume') or 0 for c in candles_asc]
|
||||
if len(vols) < 21:
|
||||
return None, scaled
|
||||
avg20 = sum(vols[-21:-1]) / 20.0
|
||||
if avg20 <= 0:
|
||||
return None, scaled
|
||||
today_v = vols[-1]
|
||||
proj_v = (today_v / vol_time_frac) if scaled and today_v else today_v
|
||||
return proj_v / avg20, scaled
|
||||
|
||||
|
||||
def ts_for_filename() -> str:
|
||||
return now_kst().strftime('%Y-%m-%d_%H-%M-%S')
|
||||
|
||||
@@ -117,6 +171,7 @@ def collect_snapshot(code: str, holding: dict | None = None) -> dict:
|
||||
sma5 = sma(closes, 5)
|
||||
sma20 = sma(closes, 20)
|
||||
sma60 = sma(closes, 60)
|
||||
sma120 = sma(closes, 120)
|
||||
|
||||
flow = kc.get_investor_flow(code, days=20)
|
||||
flow_summary = _summarize_flow(flow)
|
||||
@@ -137,6 +192,7 @@ def collect_snapshot(code: str, holding: dict | None = None) -> dict:
|
||||
'sma5': sma5,
|
||||
'sma20': sma20,
|
||||
'sma60': sma60,
|
||||
'sma120': sma120,
|
||||
'flow': flow,
|
||||
'flow_summary': flow_summary,
|
||||
'holding': holding,
|
||||
@@ -529,7 +585,7 @@ def remove_peer(stock_code: str, peer_code: str) -> bool:
|
||||
# ============================================================================
|
||||
|
||||
CHART_W = 1000
|
||||
CHART_H = 720 # crosshair 박스 영역(상단) + X축 날짜 라벨 영역 확보
|
||||
CHART_H = 660 # X축 날짜 라벨 아래 여백 최소화 (라벨 baseline VOL_BOT+26=642)
|
||||
CHART_PAD_L = 20 # 좌측 라벨 없음 (가격 라벨 우측으로 이동)
|
||||
CHART_PAD_R = 140 # 우측 가격·거래량 라벨 영역 (28px 폰트 = 6자리+쉼표 약 140px)
|
||||
CHART_PAD_T = 60 # SMA legend 영역 (y=4~50). OHLC 팝업은 차트 안 floating tooltip.
|
||||
@@ -545,12 +601,19 @@ VOL_BOT = VOL_TOP + VOL_PANEL_H
|
||||
|
||||
CHART_RANGE_DAYS = {'1Y': 250, '6M': 120, '3M': 63, '1M': 21}
|
||||
|
||||
# 사용자 수평선 색상 팔레트 — 정렬(내림차순) 인덱스로 순환. SVG·칩 UI가 같은 매핑 사용.
|
||||
# 모두 밝은 톤이라 태그 위 어두운 글자(#1a0f1f) 가독성 확보.
|
||||
HLINE_COLORS = ['#e879f9', '#38bdf8', '#fbbf24', '#4ade80', '#fb7185', '#a78bfa', '#22d3ee', '#facc15']
|
||||
|
||||
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None, intraday: bool = False) -> str:
|
||||
"""캔들 + SMA 3선 + 거래량 막대 인라인 SVG.
|
||||
|
||||
def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None = None,
|
||||
intraday: bool = False, hlines: list | None = None) -> str:
|
||||
"""캔들 + SMA 3선 + 거래량 막대(+일봉 20일 거래량 이동평균선) 인라인 SVG.
|
||||
|
||||
range_key: '1Y' (250일) | '6M' (120일) | '3M' (63일) | '1M' (21일). 같은 snapshot 데이터에서 슬라이스만.
|
||||
ref_price: 기준가(전일종가 등) — 지정 시 노란 점선 기준선 + 마지막 종가의 대비 등락률 표시 (분봉용).
|
||||
hlines: 사용자가 등록한 수평선 가격 리스트(지지/저항 등). 표시 구간 안에 드는 값만 그린다(스케일 미확장).
|
||||
회전율 값은 차트에 그리지 않고 caller가 turnover_today()로 별도 표시 (차트 아래).
|
||||
snap: collect_snapshot 결과. candles_asc·sma5/20/60 사용.
|
||||
외부 JS 의존성 0. CSS는 inline.
|
||||
"""
|
||||
@@ -563,12 +626,13 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
sma5 = (snap.get('sma5') or [])[-n_take:]
|
||||
sma20 = (snap.get('sma20') or [])[-n_take:]
|
||||
sma60 = (snap.get('sma60') or [])[-n_take:]
|
||||
sma120 = (snap.get('sma120') or [])[-n_take:]
|
||||
|
||||
# ---- 스케일 ----
|
||||
highs = [c['high'] for c in candles]
|
||||
lows = [c['low'] for c in candles]
|
||||
# SMA 값도 가격축 범위에 포함 (선이 잘리지 않도록)
|
||||
sma_vals = [v for v in (sma5 + sma20 + sma60) if v is not None]
|
||||
sma_vals = [v for v in (sma5 + sma20 + sma60 + sma120) if v is not None]
|
||||
y_max = max(max(highs), max(sma_vals) if sma_vals else 0)
|
||||
y_min = min(min(lows), min(sma_vals) if sma_vals else float('inf'))
|
||||
if ref_price:
|
||||
@@ -701,6 +765,7 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
parts.append(sma_path(sma5, '#fbbf24', 'SMA5')) # 노랑
|
||||
parts.append(sma_path(sma20, '#34d399', 'SMA20')) # 초록
|
||||
parts.append(sma_path(sma60, '#a78bfa', 'SMA60')) # 보라
|
||||
parts.append(sma_path(sma120, '#22d3ee', 'SMA120')) # 청록
|
||||
|
||||
# ---- 전일종가 기준선 + 대비 등락률 (ref_price 지정 시 — 분봉) ----
|
||||
if ref_price:
|
||||
@@ -716,16 +781,16 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
|
||||
# SMA 범례
|
||||
legend_y = PRICE_TOP + 14
|
||||
legend_items = [('SMA5', '#fbbf24'), ('SMA20', '#34d399'), ('SMA60', '#a78bfa')]
|
||||
legend_items = [('5일', '#fbbf24'), ('20일', '#34d399'), ('60일', '#a78bfa'), ('120일', '#22d3ee')]
|
||||
lx = CHART_PAD_L + 8
|
||||
for name, color in legend_items:
|
||||
parts.append(
|
||||
f'<rect x="{lx}" y="{legend_y-8}" width="10" height="3" fill="{color}"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<text x="{lx+14}" y="{legend_y-2}" fill="#cfd5df">{name}</text>'
|
||||
f'<text x="{lx+14}" y="{legend_y-2}" fill="#cfd5df" font-size="20">{name}</text>'
|
||||
)
|
||||
lx += 70
|
||||
lx += 96
|
||||
|
||||
# ---- 거래량 패널 배경 ----
|
||||
parts.append(
|
||||
@@ -746,12 +811,30 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
f'<rect x="{x-body_w/2:.2f}" y="{bar_top:.2f}" width="{body_w:.2f}" height="{bar_h:.2f}" '
|
||||
f'fill="{color}" opacity="0.7"/>'
|
||||
)
|
||||
# 거래량 20일 이동평균선 — 일봉에서만 (분봉은 '20분 평균'이 돼 의미 없음)
|
||||
vols_full = [c['volume'] for c in candles_full]
|
||||
if not intraday:
|
||||
vma = (sma(vols_full, 20))[-n_take:]
|
||||
vma_pts = [f'{slot_x(i):.2f},{vol_y(v):.2f}' for i, v in enumerate(vma) if v is not None]
|
||||
if len(vma_pts) >= 2:
|
||||
parts.append(
|
||||
f'<polyline points="{" ".join(vma_pts)}" fill="none" stroke="#f5a623" '
|
||||
f'stroke-width="2" opacity="0.9"/>'
|
||||
)
|
||||
|
||||
# 거래량 Y 레이블 (max 만) — 우측
|
||||
parts.append(
|
||||
f'<text x="{CHART_W - CHART_PAD_R + 6}" y="{VOL_TOP+22}" text-anchor="start" fill="#7a8493" font-size="28">'
|
||||
f'{_fmt_vol(v_max)}</text>'
|
||||
)
|
||||
|
||||
# 20MA 범례 (거래량 패널 하단 좌측) — 일봉 전용. RVOL·회전율 텍스트는 차트에 안 그림(차트 아래 표시).
|
||||
if not intraday:
|
||||
parts.append(
|
||||
f'<text x="{CHART_PAD_L+8}" y="{VOL_BOT-8}" text-anchor="start" fill="#f5a623" font-size="24">'
|
||||
f'— 거래량 20일평균</text>'
|
||||
)
|
||||
|
||||
# ---- X축 날짜 (시작·중간·끝) ----
|
||||
for i in (0, n // 2, n - 1):
|
||||
x = slot_x(i)
|
||||
@@ -762,6 +845,64 @@ def render_svg_chart(snap: dict, range_key: str = '1Y', ref_price: float | None
|
||||
f'{_fmt_date(candles[i]["date"])}</text>'
|
||||
)
|
||||
|
||||
# ---- 구간 고점선 + 현재가 태그 (고점·현재가 강조) ----
|
||||
# 고점: 표시 구간 내 최고 고가에 노란 점선 + 우측 '고 N' 라벨.
|
||||
hi_price = max(highs)
|
||||
hy = price_y(hi_price)
|
||||
parts.append(
|
||||
f'<line x1="{CHART_PAD_L}" y1="{hy:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{hy:.1f}" '
|
||||
f'stroke="#fbbf24" stroke-width="1" stroke-dasharray="4 4" opacity="0.55"/>'
|
||||
)
|
||||
# 고가 금액 — 가격패널 좌하단 안 (구간별 최고 고가). 상단 우측 대신 아래로 이동.
|
||||
parts.append(
|
||||
f'<text x="{CHART_PAD_L + 8}" y="{PRICE_BOT - 10:.1f}" text-anchor="start" fill="#fbbf24" '
|
||||
f'font-size="26" font-weight="600">고가 {int(round(hi_price)):,}</text>'
|
||||
)
|
||||
# 현재가(마지막 종가): 얇은 기준선 + 우측 축 강조 태그(상승=빨강/하락=파랑).
|
||||
cur_price = candles[-1]['close']
|
||||
cy = price_y(cur_price)
|
||||
last_up = candles[-1]['close'] >= candles[-1]['open']
|
||||
cur_color = '#ef4444' if last_up else '#3b82f6'
|
||||
parts.append(
|
||||
f'<line x1="{CHART_PAD_L}" y1="{cy:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{cy:.1f}" '
|
||||
f'stroke="{cur_color}" stroke-width="1" stroke-dasharray="2 3" opacity="0.5"/>'
|
||||
)
|
||||
tag_cy = min(max(cy, PRICE_TOP + 15), PRICE_BOT - 15)
|
||||
parts.append(
|
||||
f'<rect x="{CHART_W - CHART_PAD_R + 2}" y="{tag_cy-15:.1f}" width="{CHART_PAD_R-6}" height="30" '
|
||||
f'fill="{cur_color}" rx="3"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<text x="{CHART_W - CHART_PAD_R + 8}" y="{tag_cy+8:.1f}" text-anchor="start" fill="#fff" '
|
||||
f'font-size="26" font-weight="700">{int(round(cur_price)):,}</text>'
|
||||
)
|
||||
|
||||
# ---- 사용자 수평선 (hlines) — 지지/저항 등 수동 등록 가격선 ----
|
||||
# 표시 구간(y_min~y_max) 안에 드는 값만 그린다. 스케일은 확장하지 않음(캔들 가독성 우선).
|
||||
# 좌측에 분홍 가격 태그를 둔다(우측은 가격축·현재가 태그가 이미 점유).
|
||||
for _hi, hp in enumerate(hlines or []):
|
||||
try:
|
||||
hp = float(hp)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if hp < y_min or hp > y_max:
|
||||
continue
|
||||
hcolor = HLINE_COLORS[_hi % len(HLINE_COLORS)]
|
||||
hly = price_y(hp)
|
||||
parts.append(
|
||||
f'<line x1="{CHART_PAD_L}" y1="{hly:.1f}" x2="{CHART_W-CHART_PAD_R}" y2="{hly:.1f}" '
|
||||
f'stroke="{hcolor}" stroke-width="1.6" stroke-dasharray="6 4" opacity="0.9"/>'
|
||||
)
|
||||
htag_y = min(max(hly, PRICE_TOP + 15), PRICE_BOT - 15)
|
||||
parts.append(
|
||||
f'<rect x="{CHART_PAD_L+2}" y="{htag_y-15:.1f}" width="122" height="30" '
|
||||
f'fill="{hcolor}" rx="3" opacity="0.95"/>'
|
||||
)
|
||||
parts.append(
|
||||
f'<text x="{CHART_PAD_L+8}" y="{htag_y+8:.1f}" text-anchor="start" fill="#1a0f1f" '
|
||||
f'font-size="26" font-weight="700">{int(round(hp)):,}</text>'
|
||||
)
|
||||
|
||||
parts.append('</svg>')
|
||||
return ''.join(parts)
|
||||
|
||||
@@ -859,7 +1000,7 @@ def _build_data_block(snap: dict) -> str:
|
||||
lines.append(f'### 종목: {snap["name"]} ({snap["code"]})')
|
||||
lines.append(f'- 현재가: {b["price"]:,}원 ({b["change"]:+,} / {b["change_pct"]:+.2f}%)')
|
||||
lines.append(f'- 오늘 시고저: 시 {b["open"]:,} / 고 {b["high"]:,} / 저 {b["low"]:,}')
|
||||
lines.append(f'- 시가총액: {b["market_cap"]:,} (단위 키움원본) · 상장주식수: {b["listed_shares"]:,}주')
|
||||
lines.append(f'- 시가총액: {_fmt_market_cap(b["market_cap"])} · 상장주식수: {b["listed_shares"]:,}주')
|
||||
lines.append(f'- 52주 고저: 고 {b["high_52w"]:,}원 ({_fmt_date(b["high_52w_dt"])}) / 저 {b["low_52w"]:,}원 ({_fmt_date(b["low_52w_dt"])})')
|
||||
lines.append(f'- 회전율(오늘): {b["turnover_rate"]:.2f}% · 외국인 보유비율: {b["foreign_holding_pct"]:.2f}%')
|
||||
lines.append(f'- PER {b["per"]} · PBR {b["pbr"]} · ROE {b["roe"]} · EPS {b["eps"]:,} · BPS {b["bps"]:,}')
|
||||
@@ -1121,11 +1262,50 @@ def _fmt_pct_signed(v: float) -> str:
|
||||
return f'{v:+.2f}%'
|
||||
|
||||
|
||||
_EM_RE = re.compile(r'\*\*(.+?)\*\*')
|
||||
|
||||
|
||||
def _emphasize(escaped: str) -> str:
|
||||
"""이스케이프된 텍스트의 **강조** → 붉은 강조 글씨. 입력은 이미 _esc 처리된 상태."""
|
||||
return _EM_RE.sub(r'<span class="rpt-em">\1</span>', escaped)
|
||||
|
||||
|
||||
def _fmt_eok_jo(eok) -> str:
|
||||
"""억 단위 금액 → 조 단위. 1,997,447억 → 199.7조. 1조 미만은 소수 둘째자리. 비숫자는 —."""
|
||||
if not isinstance(eok, (int, float)):
|
||||
return '—'
|
||||
jo = eok / 10000
|
||||
return f'{jo:,.2f}조' if abs(jo) < 1 else f'{jo:,.1f}조'
|
||||
|
||||
|
||||
def _fmt_market_cap(eok) -> str:
|
||||
"""키움 mac(억원) → 사람이 읽는 '2,084조 1,983억원'. 0/None은 '—'."""
|
||||
if not isinstance(eok, (int, float)) or eok <= 0:
|
||||
return '—'
|
||||
eok = int(eok)
|
||||
jo, rem = divmod(eok, 10000)
|
||||
if jo and rem:
|
||||
return f'{jo:,}조 {rem:,}억원'
|
||||
if jo:
|
||||
return f'{jo:,}조원'
|
||||
return f'{rem:,}억원'
|
||||
|
||||
|
||||
def _fmt_date(s) -> str:
|
||||
"""20260519 → 2026-05-19. 형식 다르면 원본 반환."""
|
||||
"""날짜를 yy/mm/dd로. 20260519 · 2026-05-19 · 2026/05/19 → 26/05/19. 형식 다르면 원본."""
|
||||
s = str(s or '').strip()
|
||||
if len(s) == 8 and s.isdigit():
|
||||
return f'{s[:4]}-{s[4:6]}-{s[6:]}'
|
||||
d = s.replace('-', '').replace('/', '').replace('.', '')
|
||||
if len(d) == 8 and d.isdigit():
|
||||
return f'{d[2:4]}/{d[4:6]}/{d[6:]}'
|
||||
return s
|
||||
|
||||
|
||||
def _fmt_period(s) -> str:
|
||||
"""회계기간 2021/12 · 2021.12 · 202112 → 21/12. 형식 다르면 원본."""
|
||||
s = str(s or '').strip()
|
||||
d = s.replace('-', '').replace('/', '').replace('.', '')
|
||||
if len(d) == 6 and d.isdigit():
|
||||
return f'{d[2:4]}/{d[4:6]}'
|
||||
return s
|
||||
|
||||
|
||||
@@ -1158,9 +1338,9 @@ _KV_HINTS = {
|
||||
'BPS': '주당순자산. 1주당 회사가 가진 순자산 가치(원).',
|
||||
'외국인 보유비율': '전체 발행 주식 중 외국인이 보유한 비율(%).',
|
||||
# ── 성장성·펀더멘털 (FnGuide) ──
|
||||
'매출액(억)': '회사가 1년에 물건·서비스를 팔아 벌어들인 총액(억원). 회사 덩치가 커지는지 보는 지표.',
|
||||
'매출액(조)': '회사가 1년에 물건·서비스를 팔아 벌어들인 총액(조원). 회사 덩치가 커지는지 보는 지표.',
|
||||
'매출증가율': '작년보다 매출이 몇 % 늘었는지. +면 성장, −면 역성장. 꾸준히 +면 좋아요.',
|
||||
'영업이익(억)': '본업으로 번 이익(억원). 매출에서 원가·판관비를 뺀 것. 회사가 장사를 잘하는지.',
|
||||
'영업이익(조)': '본업으로 번 이익(조원). 매출에서 원가·판관비를 뺀 것. 회사가 장사를 잘하는지.',
|
||||
'EPS(원)': '주당순이익. 1주가 1년에 번 순이익(원). 높을수록, 그리고 늘어날수록 좋아요.',
|
||||
'EPS증가율': '작년보다 주당순이익이 몇 % 늘었는지. 이익이 실제로 커지는지 보는 핵심 지표.',
|
||||
'목표주가': '증권사들이 "이 정도까진 오를 만하다"고 본 가격의 평균. 현재가보다 높으면 상승 기대.',
|
||||
@@ -1173,7 +1353,7 @@ _KV_HINTS = {
|
||||
'목표주가 리비전': '최근 3개월간 증권사 목표주가 평균이 오르고(+) 있는지 내리고(−) 있는지. 오르는 중이면 시장 기대가 좋아지는 신호.',
|
||||
'추정치 리비전': '최근 3개월간 예상 실적(EPS 등)이 상향(+)/하향(−)되는 중인지. 상향이면 전망이 밝아지는 거예요.',
|
||||
'서프라이즈': '실제 발표 실적이 직전 예상치보다 잘 나왔으면(+) 어닝 서프라이즈, 못 나왔으면(−) 어닝 쇼크.',
|
||||
'실적(억)': '실제로 확정된 작년 실적 금액(억원). 예상치가 아니라 진짜 나온 숫자.',
|
||||
'실적(조)': '실제로 확정된 작년 실적 금액(조원). 예상치가 아니라 진짜 나온 숫자.',
|
||||
}
|
||||
|
||||
|
||||
@@ -1265,23 +1445,23 @@ def _render_fundamentals_html(fund: dict | None) -> str:
|
||||
roe = r.get('roe')
|
||||
body.append(
|
||||
'<tr>'
|
||||
f'<td>{_esc(r.get("period",""))}</td>'
|
||||
f'<td style="text-align:right">{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else "—"}</td>'
|
||||
f'<td>{_esc(_fmt_period(r.get("period","")))}</td>'
|
||||
f'<td style="text-align:right">{_fmt_eok_jo(sales)}</td>'
|
||||
f'<td style="text-align:right">{_fmt_growth(r.get("sales_growth"))}</td>'
|
||||
f'<td style="text-align:right">{_fmt_num(round(oper)) if isinstance(oper,(int,float)) else "—"}</td>'
|
||||
f'<td style="text-align:right">{_fmt_eok_jo(oper)}</td>'
|
||||
f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else "—"}</td>'
|
||||
f'<td style="text-align:right">{_fmt_growth(r.get("eps_growth"))}</td>'
|
||||
f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else "—"}</td>'
|
||||
'</tr>'
|
||||
)
|
||||
table_html = (
|
||||
'<table class="flowtable">'
|
||||
'<div class="tbl-scroll"><table class="flowtable">'
|
||||
'<thead><tr>'
|
||||
f'<th>기간</th><th>{_lbl("매출액(억)")}</th><th>{_lbl("매출증가율")}</th>'
|
||||
f'<th>{_lbl("영업이익(억)")}</th><th>{_lbl("EPS(원)")}</th>'
|
||||
f'<th>기간</th><th>{_lbl("매출액(조)")}</th><th>{_lbl("매출증가율")}</th>'
|
||||
f'<th>{_lbl("영업이익(조)")}</th><th>{_lbl("EPS(원)")}</th>'
|
||||
f'<th>{_lbl("EPS증가율")}</th><th>{_lbl("ROE")}</th>'
|
||||
'</tr></thead>'
|
||||
'<tbody>' + ''.join(body) + '</tbody></table>'
|
||||
'<tbody>' + ''.join(body) + '</tbody></table></div>'
|
||||
)
|
||||
|
||||
# ---- 컨센서스 카드 ----
|
||||
@@ -1307,7 +1487,7 @@ def _render_fundamentals_html(fund: dict | None) -> str:
|
||||
parts.append(f'<tr><th>{_lbl("참여 기관수")}</th><td>{int(oc)}개</td></tr>')
|
||||
if parts:
|
||||
cdate = consensus.get('date')
|
||||
date_html = (f'<span style="color:#7a8493;font-size:11px"> · 기준 {_esc(cdate)}</span>'
|
||||
date_html = (f'<span style="color:#7a8493;font-size:11px"> · 기준 {_esc(_fmt_date(cdate))}</span>'
|
||||
if cdate else '')
|
||||
cons_html = (
|
||||
f'<h4 style="margin:10px 0 4px">📋 컨센서스{date_html}</h4>'
|
||||
@@ -1372,7 +1552,7 @@ def _render_consensus_html(cons: dict | None) -> str:
|
||||
sup_rows.append(
|
||||
'<tr>'
|
||||
f'<td>{_esc(label)}</td>'
|
||||
f'<td style="text-align:right">{_fmt_num(round(act)) if isinstance(act,(int,float)) else "—"}</td>'
|
||||
f'<td style="text-align:right">{_fmt_eok_jo(act)}</td>'
|
||||
f'<td style="text-align:right">{_fmt_growth(sp)}</td>'
|
||||
'</tr>'
|
||||
)
|
||||
@@ -1382,7 +1562,7 @@ def _render_consensus_html(cons: dict | None) -> str:
|
||||
f'<h4 style="margin:10px 0 4px">🎯 어닝 서프라이즈 '
|
||||
f'<span style="color:#7a8493;font-size:11px">({_esc(any_year)} 실적 vs 직전 컨센서스)</span></h4>'
|
||||
'<table class="flowtable">'
|
||||
f'<thead><tr><th>항목</th><th>{_lbl("실적(억)")}</th><th>{_lbl("서프라이즈")}</th></tr></thead>'
|
||||
f'<thead><tr><th>항목</th><th>{_lbl("실적(조)")}</th><th>{_lbl("서프라이즈")}</th></tr></thead>'
|
||||
'<tbody>' + ''.join(sup_rows) + '</tbody></table>'
|
||||
)
|
||||
|
||||
@@ -1396,10 +1576,10 @@ def _render_consensus_html(cons: dict | None) -> str:
|
||||
sales = r.get('sales'); op = r.get('op'); eps = r.get('eps'); roe = r.get('roe')
|
||||
body.append(
|
||||
'<tr' + (' style="opacity:.85"' if est else '') + '>'
|
||||
f'<td>{_esc(r.get("period",""))}{tag}</td>'
|
||||
f'<td style="text-align:right">{_fmt_num(round(sales)) if isinstance(sales,(int,float)) else "—"}</td>'
|
||||
f'<td>{_esc(_fmt_period(r.get("period","")))}{tag}</td>'
|
||||
f'<td style="text-align:right">{_fmt_eok_jo(sales)}</td>'
|
||||
f'<td style="text-align:right">{_fmt_growth(r.get("sales_yoy"))}</td>'
|
||||
f'<td style="text-align:right">{_fmt_num(round(op)) if isinstance(op,(int,float)) else "—"}</td>'
|
||||
f'<td style="text-align:right">{_fmt_eok_jo(op)}</td>'
|
||||
f'<td style="text-align:right">{_fmt_num(round(eps)) if isinstance(eps,(int,float)) else "—"}</td>'
|
||||
f'<td style="text-align:right">{(str(roe)+"%") if isinstance(roe,(int,float)) else "—"}</td>'
|
||||
'</tr>'
|
||||
@@ -1407,10 +1587,10 @@ def _render_consensus_html(cons: dict | None) -> str:
|
||||
parts.append(
|
||||
'<h4 style="margin:10px 0 4px">📅 연도별 추정 재무 '
|
||||
'<span style="color:#7a8493;font-size:11px">(IFRS연결 · A 실적 / E 추정)</span></h4>'
|
||||
'<table class="flowtable">'
|
||||
f'<thead><tr><th>기간</th><th>{_lbl("매출액(억)")}</th><th>{_lbl("YoY")}</th>'
|
||||
f'<th>{_lbl("영업이익(억)")}</th><th>{_lbl("EPS(원)")}</th><th>{_lbl("ROE")}</th></tr></thead>'
|
||||
'<tbody>' + ''.join(body) + '</tbody></table>'
|
||||
'<div class="tbl-scroll"><table class="flowtable">'
|
||||
f'<thead><tr><th>기간</th><th>{_lbl("매출액(조)")}</th><th>{_lbl("YoY")}</th>'
|
||||
f'<th>{_lbl("영업이익(조)")}</th><th>{_lbl("EPS(원)")}</th><th>{_lbl("ROE")}</th></tr></thead>'
|
||||
'<tbody>' + ''.join(body) + '</tbody></table></div>'
|
||||
)
|
||||
|
||||
if not parts:
|
||||
@@ -1477,6 +1657,12 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
v = p.get(field)
|
||||
return f'{v:,}{suffix}' if isinstance(v, (int, float)) else '—'
|
||||
|
||||
def _peer_mac(p: dict) -> str:
|
||||
if p.get('error'):
|
||||
return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
|
||||
v = p.get('market_cap')
|
||||
return _fmt_market_cap(v) if isinstance(v, (int, float)) else '—'
|
||||
|
||||
def _peer_pct(p: dict, field: str) -> str:
|
||||
if p.get('error'):
|
||||
return '<span style="color:#ef4444;font-size:11px">조회 실패</span>'
|
||||
@@ -1501,8 +1687,7 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
('오늘 시가', f'{b["open"]:,}원', lambda p: _peer_int(p, 'open', '원')),
|
||||
('오늘 고가', f'{b["high"]:,}원', lambda p: _peer_int(p, 'high', '원')),
|
||||
('오늘 저가', f'{b["low"]:,}원', lambda p: _peer_int(p, 'low', '원')),
|
||||
('시가총액', f'{b["market_cap"]:,} <span style="color:#7a8493;font-size:11px">(키움 원본 단위)</span>',
|
||||
lambda p: _peer_int(p, 'market_cap', '')),
|
||||
('시가총액', _fmt_market_cap(b["market_cap"]), _peer_mac),
|
||||
('상장주식수', f'{b["listed_shares"]:,}주', lambda p: _peer_int(p, 'listed_shares', '주')),
|
||||
('52주 최고', f'{b["high_52w"]:,}원 <span style="color:#7a8493;font-size:11px">({_fmt_date(b["high_52w_dt"])})</span>',
|
||||
lambda p: _peer_52w(p, 'high_52w', 'high_52w_dt')),
|
||||
@@ -1552,6 +1737,20 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
chart_1y = render_svg_chart(snap, '1Y')
|
||||
chart_6m = render_svg_chart(snap, '6M')
|
||||
chart_1m = render_svg_chart(snap, '1M')
|
||||
# 오늘 회전율·RVOL 값 — 차트 아래 표시. 장중 생성 시 종가 기준 환산('예상').
|
||||
_vfrac = session_vol_frac(snap.get('candles_asc') or [])
|
||||
_cands = snap.get('candles_asc') or []
|
||||
_turn, _tscaled = turnover_today(_cands, _vfrac)
|
||||
_rvol, _rscaled = rvol_today(_cands, _vfrac)
|
||||
_cap_bits = []
|
||||
if _rvol is not None:
|
||||
_cap_bits.append(f'<b>RVOL</b> x{_rvol:.1f}')
|
||||
if _turn is not None:
|
||||
_cap_bits.append(f'<b>회전율</b> {_turn:.2f}%')
|
||||
_turn_cap = (
|
||||
f'<div class="chart-cap">{" · ".join(_cap_bits)}{" (예상)" if (_tscaled or _rscaled) else ""}</div>'
|
||||
if _cap_bits else ''
|
||||
)
|
||||
chart_block = (
|
||||
'<div class="chart-tabs">'
|
||||
'<button type="button" data-range="1Y" class="active">1년</button>'
|
||||
@@ -1561,6 +1760,7 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
f'<div class="chart-panel active" data-range="1Y">{chart_1y}</div>'
|
||||
f'<div class="chart-panel" data-range="6M">{chart_6m}</div>'
|
||||
f'<div class="chart-panel" data-range="1M">{chart_1m}</div>'
|
||||
+ _turn_cap
|
||||
)
|
||||
|
||||
# ---- 외국인/기관 수급 표 (최근 20일) ----
|
||||
@@ -1583,11 +1783,11 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
'<thead><tr><th>일자</th><th>종가</th><th>외국인</th><th>기관</th><th>개인</th></tr></thead>'
|
||||
'<tbody>' + ''.join(flow_rows) + '</tbody>'
|
||||
'</table>'
|
||||
f'<p class="flowsum">20일 누적 — '
|
||||
f'<p class="flowsum"><b>20일 누적</b><br>'
|
||||
f'<b style="color:{"#ef4444" if fs["foreign_net"]>0 else "#3b82f6"}">외국인 {fs["foreign_net"]*1000:+,}주</b> '
|
||||
f'(매수일 {fs["foreign_buy_days"]}/20) · '
|
||||
f'(매수일 {fs["foreign_buy_days"]}/20)<br>'
|
||||
f'<b style="color:{"#ef4444" if fs["institution_net"]>0 else "#3b82f6"}">기관 {fs["institution_net"]*1000:+,}주</b> '
|
||||
f'(매수일 {fs["institution_buy_days"]}/20) · '
|
||||
f'(매수일 {fs["institution_buy_days"]}/20)<br>'
|
||||
f'개인 {fs["individual_net"]*1000:+,}주</p>'
|
||||
)
|
||||
|
||||
@@ -1616,7 +1816,7 @@ def build_report_html(snap: dict, parsed: dict) -> str:
|
||||
paras = [p.strip() for p in s['body'].split('\n\n') if p.strip()]
|
||||
if not paras:
|
||||
paras = [s['body']]
|
||||
body_html = ''.join(f'<p>{_esc(para).replace(chr(10), "<br>")}</p>' for para in paras)
|
||||
body_html = ''.join(f'<p>{_emphasize(_esc(para)).replace(chr(10), "<br>")}</p>' for para in paras)
|
||||
comment_html_parts.append(
|
||||
f'<div class="rpt-llm-block"><h4>{title}</h4>{body_html}</div>'
|
||||
)
|
||||
@@ -1786,16 +1986,19 @@ body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFo
|
||||
/* 보조 섹션 2단(넓으면 다단) 그리드 — 한 화면에 모이게 */
|
||||
.rpt-cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:14px;align-items:start;margin:14px 0}
|
||||
.rpt-cols>.rpt-section{margin:0;height:100%}
|
||||
.kvtable{border-collapse:collapse;width:100%}
|
||||
.kvtable th{text-align:left;padding:7px 10px;color:#7a8493;font-weight:500;width:160px;border-bottom:1px solid #1f2937;font-size:13px}
|
||||
.kvtable td{padding:7px 10px;border-bottom:1px solid #1f2937}
|
||||
.flowtable{border-collapse:collapse;width:100%;font-size:13px}
|
||||
.flowtable th{padding:6px 10px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left}
|
||||
.flowtable td{padding:5px 10px;border-bottom:1px solid #1a1f28}
|
||||
.flowsum{margin:10px 0 0;padding:10px;background:#0f1419;border-radius:6px;font-size:13px}
|
||||
.kvtable{border-collapse:collapse;width:100%;font-size:12px}
|
||||
.kvtable th{text-align:left;padding:6px 9px;color:#7a8493;font-weight:500;width:160px;border-bottom:1px solid #1f2937;font-size:12px}
|
||||
.kvtable td{padding:6px 9px;border-bottom:1px solid #1f2937}
|
||||
.tbl-scroll{overflow-x:auto;-webkit-overflow-scrolling:touch}
|
||||
.tbl-scroll>table{min-width:100%;width:max-content}
|
||||
.flowtable{border-collapse:collapse;width:100%;font-size:12px}
|
||||
.flowtable th{padding:5px 9px;color:#7a8493;border-bottom:1px solid #2a2f3a;text-align:left;white-space:nowrap}
|
||||
.flowtable td{padding:5px 9px;border-bottom:1px solid #1a1f28;white-space:nowrap}
|
||||
.flowsum{margin:10px 0 0;padding:10px;background:#0f1419;border-radius:6px;font-size:13px;line-height:1.8}
|
||||
.rpt-llm-block{margin:16px 0;padding:14px;background:#0f1419;border-radius:6px}
|
||||
.rpt-llm-block h4{margin:0 0 10px;color:#fbbf24;font-size:14px}
|
||||
.rpt-llm-block p{margin:8px 0;text-align:justify}
|
||||
.rpt-em{color:#ff6b6b;font-weight:600}
|
||||
.rpt-verdict{background:linear-gradient(135deg,#1f2937 0%,#161b22 100%)}
|
||||
.verdict-line{font-size:16px;font-weight:600;margin:10px 0 6px;color:#fff}
|
||||
.rpt-disclaimer{color:#7a8493;font-size:12px;margin:6px 0 0}
|
||||
@@ -1806,6 +2009,8 @@ body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFo
|
||||
.chart-tabs button.active{background:rgba(251,191,36,0.12);color:#fbbf24;border-color:#fbbf24}
|
||||
.chart-panel{display:none}
|
||||
.chart-panel.active{display:block}
|
||||
.chart-cap{margin:8px 0 4px;padding:8px 10px 0;font-size:9px;line-height:1.5;color:#7a8493;border-top:1px solid #1d222b}
|
||||
.chart-cap b{color:#9aa4b2;font-weight:600}
|
||||
.kvtable th{vertical-align:top}
|
||||
.info-label{cursor:pointer;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;border-bottom:1px dotted #5b6473;display:inline-flex;align-items:center;gap:4px;color:#7a8493;padding:1px 2px}
|
||||
.info-label::after{content:'ⓘ';font-size:11px;color:#5b6473;font-weight:normal}
|
||||
@@ -1836,8 +2041,8 @@ body{background:#0f1419;color:#cfd5df;font-family:-apple-system,BlinkMacSystemFo
|
||||
.peertable th:not(:first-child){text-align:right}
|
||||
.peertable td{padding:7px 10px;border-bottom:1px solid #1a1f28}
|
||||
.peers-note{margin:10px 0 0;color:#7a8493;font-size:11px}
|
||||
.basicpeertable{border-collapse:collapse;width:100%;font-size:13px;table-layout:fixed}
|
||||
.basicpeertable th.lbl{text-align:left;padding:7px 10px;color:#7a8493;font-weight:500;width:140px;border-bottom:1px solid #1f2937;font-size:13px;vertical-align:top}
|
||||
.basicpeertable{border-collapse:collapse;width:100%;font-size:12px;table-layout:fixed}
|
||||
.basicpeertable th.lbl{text-align:left;padding:6px 9px;color:#7a8493;font-weight:500;width:130px;border-bottom:1px solid #1f2937;font-size:12px;vertical-align:top}
|
||||
.basicpeertable thead th{padding:8px 10px;color:#cfd5df;border-bottom:1px solid #2a2f3a;font-weight:600;text-align:left;background:#0f1419}
|
||||
.basicpeertable thead th:nth-child(2){color:#fbbf24}
|
||||
.basicpeertable td{padding:7px 10px;border-bottom:1px solid #1f2937;vertical-align:top;word-break:break-word}
|
||||
|
||||
@@ -37,6 +37,7 @@ from kiwoom_client import ( # noqa: E402
|
||||
KST = ZoneInfo('Asia/Seoul')
|
||||
STATE_DIR = WORKSPACE / 'state'
|
||||
SNAPSHOT_FILE = STATE_DIR / 'portfolio_daily_snapshot.json'
|
||||
STOCK_CODES_FILE = STATE_DIR / 'stock_codes.json'
|
||||
RECIPIENT = 'mini.snowoyh@gmail.com'
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
TELEGRAM_ACCOUNT = 'stock' # 레이 봇
|
||||
@@ -161,6 +162,61 @@ def apply_ka10001_corrections(rows: list[dict]) -> int:
|
||||
return applied
|
||||
|
||||
|
||||
def correct_nxt_inactive_rows(rows: list[dict]) -> int:
|
||||
"""NXT 미거래 보유종목을 KRX 시세로 보정한다.
|
||||
|
||||
kt00018 NXT 잔고 조회는 NXT 미거래 종목에서 KRX 최신가 대신 이전 거래일의
|
||||
stale 가격을 반환할 수 있다. 종목 메타의 ``nxt_enable=false`` 인 코드만 KRX
|
||||
ka10095 배치시세로 교체해 리포트와 일일 스냅샷에 같은 오류가 저장되지 않게 한다.
|
||||
"""
|
||||
codes = {r.get('code') for r in rows if r.get('code')}
|
||||
if not codes:
|
||||
return 0
|
||||
|
||||
cache = load_json(STOCK_CODES_FILE, {})
|
||||
stocks = cache.get('stocks', {}) if isinstance(cache, dict) else {}
|
||||
inactive = {
|
||||
info.get('code')
|
||||
for info in stocks.values()
|
||||
if isinstance(info, dict)
|
||||
and info.get('code') in codes
|
||||
and info.get('nxt_enable') is False
|
||||
}
|
||||
if not inactive:
|
||||
return 0
|
||||
|
||||
try:
|
||||
quotes = get_watchlist_quotes(sorted(inactive), exchange='KRX')
|
||||
except Exception as e:
|
||||
print(f'[nxt-inactive-krx] fetch failed: {e}', file=sys.stderr)
|
||||
return 0
|
||||
|
||||
corrected = 0
|
||||
for r in rows:
|
||||
if r.get('code') not in inactive:
|
||||
continue
|
||||
q = quotes.get(r['code']) or {}
|
||||
price = q.get('price', 0) or 0
|
||||
if price <= 0:
|
||||
continue
|
||||
change = q.get('change', 0) or 0
|
||||
qty = r.get('qty', 0) or 0
|
||||
buy_amount = r.get('buy_amount', 0) or 0
|
||||
eval_value = price * qty
|
||||
profit = eval_value - buy_amount
|
||||
r.update({
|
||||
'price': price,
|
||||
'pred_close': price - change,
|
||||
'day_change': change,
|
||||
'day_change_pct': q.get('change_pct', 0.0) or 0.0,
|
||||
'eval_value': eval_value,
|
||||
'profit': profit,
|
||||
'profit_rate': (profit / buy_amount * 100) if buy_amount else 0.0,
|
||||
})
|
||||
corrected += 1
|
||||
return corrected
|
||||
|
||||
|
||||
def collect_rows() -> tuple[list[dict], dict, dict]:
|
||||
"""계좌별 포지션 → 평탄화된 row 리스트 + 계좌 잔고 + 계좌별 당일매매일지.
|
||||
|
||||
@@ -192,6 +248,9 @@ def collect_rows() -> tuple[list[dict], dict, dict]:
|
||||
'tdy_buyq': p['tdy_buyq'],
|
||||
'tdy_sellq': p['tdy_sellq'],
|
||||
})
|
||||
corrected = correct_nxt_inactive_rows(rows)
|
||||
if corrected:
|
||||
print(f'[nxt-inactive-krx] {corrected}개 보유행 KRX 시세 보정', file=sys.stderr)
|
||||
balances = {a['label']: get_balance(a['label']) for a in list_accounts()}
|
||||
journal_by_label = get_trade_journal_all()
|
||||
return rows, balances, journal_by_label
|
||||
@@ -520,14 +579,34 @@ def _render_html_owner(idx: int, owner: str, d: dict, balances: dict, prev_key,
|
||||
)
|
||||
|
||||
# behive_web _render_owner_kpi 와 동일한 순서·라벨로 통일.
|
||||
# 1) 총평가 → 2) 총매입 → 3) 총평가손익 → 4) 당일평가손익 → 5) 예수금 → 6) 순자산 → 7) 보유 종목 → 8) 당일 매매
|
||||
# '총'은 계좌 전체(현금 포함)일 때만: 총자산 = 평가금액 + 예수금.
|
||||
# 총자산 → 매입금액 → 평가금액 → 평가손익 → 예수금 → [당일 입출금] → 보유종목/당일매매 → 당일 평가손익
|
||||
held = [r for r in d['consolidated'] if not r.get('phantom')]
|
||||
parts.append(kpi('총 평가금액', f'{d["total_value"]:,}원'))
|
||||
parts.append(kpi('총 매입금액', f'{d["total_cost"]:,}원'))
|
||||
parts.append(kpi('총자산', f'{d["total_net"]:,}원'))
|
||||
parts.append(kpi('매입금액', f'{d["total_cost"]:,}원'))
|
||||
parts.append(kpi('평가금액', f'{d["total_value"]:,}원'))
|
||||
parts.append(kpi(
|
||||
'총 평가손익',
|
||||
'평가손익',
|
||||
f'{_signed(d["total_profit"])} ({_signed_pct(d["total_profit_rate"])})'
|
||||
))
|
||||
parts.append(kpi('예수금', f'{d["deposit"]:,}원'))
|
||||
# 당일 입출금은 예수금 바로 아래 (현금 잔액 ↔ 그날 현금 이동)
|
||||
cash_in = d.get('cash_in', 0)
|
||||
cash_out = d.get('cash_out', 0)
|
||||
if cash_in or cash_out:
|
||||
net = cash_in - cash_out
|
||||
nsign = '+' if net >= 0 else ''
|
||||
ncolor = '#0a7' if net > 0 else ('#d33' if net < 0 else '#666')
|
||||
# 한쪽만 있으면 순액과 같은 값이라 생략 (둘 다 있을 때만 쪼갠다)
|
||||
detail = ''
|
||||
if cash_in and cash_out:
|
||||
detail = (f' <span style="color:#888;font-size:13px;">'
|
||||
f'· 입금 {cash_in:,}원 · 출금 {cash_out:,}원</span>')
|
||||
parts.append(kpi(
|
||||
'당일 입출금',
|
||||
f'<span style="color:{ncolor};">{nsign}{net:,}원</span>{detail}'
|
||||
))
|
||||
parts.append(kpi('보유종목/당일매매', f'{len(held)}개 / {len(d["traded_today"])}개'))
|
||||
day_pl_total = d.get('day_pl_total')
|
||||
if day_pl_total is None:
|
||||
if prev_key:
|
||||
@@ -540,26 +619,6 @@ def _render_html_owner(idx: int, owner: str, d: dict, balances: dict, prev_key,
|
||||
pct_html = f' ({_signed_pct(pct)})'
|
||||
suffix = f' <span style="color:#888;font-size:13px;">· {prev_key} 대비</span>' if prev_key else ''
|
||||
parts.append(kpi('당일 평가손익', f'{_signed(day_pl_total)}{pct_html}{suffix}'))
|
||||
parts.append(kpi('예수금', f'{d["deposit"]:,}원'))
|
||||
parts.append(kpi('순자산', f'{d["total_net"]:,}원'))
|
||||
cash_in = d.get('cash_in', 0)
|
||||
cash_out = d.get('cash_out', 0)
|
||||
if cash_in or cash_out:
|
||||
net = cash_in - cash_out
|
||||
nsign = '+' if net >= 0 else ''
|
||||
ncolor = '#0a7' if net > 0 else ('#d33' if net < 0 else '#666')
|
||||
detail_parts = []
|
||||
if cash_in:
|
||||
detail_parts.append(f'입금 {cash_in:,}원')
|
||||
if cash_out:
|
||||
detail_parts.append(f'출금 {cash_out:,}원')
|
||||
detail = f' <span style="color:#888;font-size:13px;">· {" · ".join(detail_parts)}</span>' if detail_parts else ''
|
||||
parts.append(kpi(
|
||||
'당일 입출금',
|
||||
f'<span style="color:{ncolor};">{nsign}{net:,}원</span>{detail}'
|
||||
))
|
||||
parts.append(kpi('보유 종목', f'{len(held)}개'))
|
||||
parts.append(kpi('당일 매매', f'{len(d["traded_today"])}개'))
|
||||
realized_pl = d.get('realized_pl_total', 0)
|
||||
realized_fees = d.get('realized_fees_total', 0)
|
||||
if d.get('traded_today') and (realized_pl or realized_fees):
|
||||
@@ -710,13 +769,29 @@ def _render_html_body(owner_data: dict, ordered: list, balances: dict, today_key
|
||||
f'<td style="{S["kpi_value"]}">{value_html}</td></tr>'
|
||||
)
|
||||
|
||||
# behive_web KPI 순서로 통일
|
||||
parts.append(kpi('총 평가금액', f'{grand_value:,}원'))
|
||||
parts.append(kpi('총 매입금액', f'{grand_cost:,}원'))
|
||||
# behive_web KPI 순서·라벨로 통일 (블록 자체가 전체 합산이라 '총' 중복 접두 제거)
|
||||
parts.append(kpi('총자산', f'{grand_net:,}원'))
|
||||
parts.append(kpi('매입금액', f'{grand_cost:,}원'))
|
||||
parts.append(kpi('평가금액', f'{grand_value:,}원'))
|
||||
parts.append(kpi(
|
||||
'총 평가손익',
|
||||
'평가손익',
|
||||
f'{_signed(grand_profit)} ({_signed_pct(grand_rate)})'
|
||||
))
|
||||
parts.append(kpi('예수금', f'{grand_deposit:,}원'))
|
||||
# 당일 입출금은 예수금 바로 아래
|
||||
if grand_cash_in or grand_cash_out:
|
||||
gn = grand_net_cash_flow
|
||||
gsign = '+' if gn >= 0 else ''
|
||||
gcolor = '#0a7' if gn > 0 else ('#d33' if gn < 0 else '#666')
|
||||
# 한쪽만 있으면 순액과 같은 값이라 생략
|
||||
gdetail = ''
|
||||
if grand_cash_in and grand_cash_out:
|
||||
gdetail = (f' <span style="color:#888;font-size:13px;">'
|
||||
f'· 입금 {grand_cash_in:,}원 · 출금 {grand_cash_out:,}원</span>')
|
||||
parts.append(kpi(
|
||||
'당일 입출금',
|
||||
f'<span style="color:{gcolor};">{gsign}{gn:,}원</span>{gdetail}'
|
||||
))
|
||||
if grand_day_pl_total is not None:
|
||||
pct_html = ''
|
||||
if grand_prev_net:
|
||||
@@ -726,22 +801,6 @@ def _render_html_body(owner_data: dict, ordered: list, balances: dict, today_key
|
||||
parts.append(kpi('당일 평가손익', f'{_signed(grand_day_pl_total)}{pct_html}{suffix}'))
|
||||
elif prev_key:
|
||||
parts.append(kpi('당일 평가손익', '<span style="color:#888;">전날 스냅샷 없음</span>'))
|
||||
parts.append(kpi('총 예수금', f'{grand_deposit:,}원'))
|
||||
parts.append(kpi('총 순자산', f'{grand_net:,}원'))
|
||||
if grand_cash_in or grand_cash_out:
|
||||
gn = grand_net_cash_flow
|
||||
gsign = '+' if gn >= 0 else ''
|
||||
gcolor = '#0a7' if gn > 0 else ('#d33' if gn < 0 else '#666')
|
||||
gd = []
|
||||
if grand_cash_in:
|
||||
gd.append(f'입금 {grand_cash_in:,}원')
|
||||
if grand_cash_out:
|
||||
gd.append(f'출금 {grand_cash_out:,}원')
|
||||
gdetail = f' <span style="color:#888;font-size:13px;">· {" · ".join(gd)}</span>'
|
||||
parts.append(kpi(
|
||||
'당일 입출금',
|
||||
f'<span style="color:{gcolor};">{gsign}{gn:,}원</span>{gdetail}'
|
||||
))
|
||||
grand_realized_pl = sum(d.get('realized_pl_total', 0) for d in owner_data.values())
|
||||
grand_realized_fees = sum(d.get('realized_fees_total', 0) for d in owner_data.values())
|
||||
if any(d.get('traded_today') for d in owner_data.values()) and (grand_realized_pl or grand_realized_fees):
|
||||
@@ -767,6 +826,20 @@ def build_report(by_account: bool = False) -> tuple[str, str, str]:
|
||||
raise RuntimeError('키움 REST 응답에 보유종목이 없습니다.')
|
||||
|
||||
today_key = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
|
||||
# 당일 매수 동반 매도행의 pl을 '전일 보유 평단' 기준으로 재계산 (ka10170 round-trip 오표시 교정).
|
||||
# 자산웹과 동일 기준 — 전일 평단은 파일 이력 재생(prev_avg_map, 당일분 제외)에서.
|
||||
try:
|
||||
import trade_journal as tj
|
||||
_prev_avgs = tj.prev_avg_map(today_key)
|
||||
for _lb, _trs in journal_by_label.items():
|
||||
for _t in _trs:
|
||||
_pa = _prev_avgs.get((_lb, _t.get('code')), 0)
|
||||
if (_t.get('sell_qty') or 0) > 0 and (_t.get('buy_qty') or 0) > 0 and _pa > 0:
|
||||
tj._apply_rebase(_t, float(_t.get('sell_avg') or 0), int(_t['sell_qty']), _pa)
|
||||
except Exception as e:
|
||||
print(f'[journal rebase] failed: {e}', file=sys.stderr)
|
||||
|
||||
all_labels = [a['label'] for a in list_accounts()]
|
||||
owner_groups = group_by_owner(all_labels)
|
||||
|
||||
@@ -998,14 +1071,18 @@ def build_report(by_account: bool = False) -> tuple[str, str, str]:
|
||||
title = OWNER_LABELS.get(owner, f'{owner} 자산')
|
||||
labels_disp = ' + '.join(d['labels'])
|
||||
held = [r for r in d['consolidated'] if not r.get('phantom')]
|
||||
# behive_web KPI 순서·라벨로 통일
|
||||
# behive_web KPI 순서·라벨로 통일. 라벨은 표시폭 17칸 정렬(한글 2칸 기준).
|
||||
lines += [
|
||||
'==============================',
|
||||
f"{idx}) {title} ({labels_disp})",
|
||||
'==============================',
|
||||
f'총 평가금액 : {d["total_value"]:,}원',
|
||||
f'총 매입금액 : {d["total_cost"]:,}원',
|
||||
f'총 평가손익 : {d["total_profit"]:+,}원 ({d["total_profit_rate"]:+.2f}%)',
|
||||
f'총자산 : {d["total_net"]:,}원',
|
||||
f'매입금액 : {d["total_cost"]:,}원',
|
||||
f'평가금액 : {d["total_value"]:,}원',
|
||||
f'평가손익 : {d["total_profit"]:+,}원 ({d["total_profit_rate"]:+.2f}%)',
|
||||
f'예수금 : {d["deposit"]:,}원',
|
||||
# '보유종목/당일매매'는 표시폭이 정확히 17칸이라 패딩 없이 콜론이 맞는다.
|
||||
f'보유종목/당일매매: {len(held)}개 / {len(d["traded_today"])}개',
|
||||
]
|
||||
day_pl = d.get('day_pl_total')
|
||||
if day_pl is not None:
|
||||
@@ -1015,10 +1092,6 @@ def build_report(by_account: bool = False) -> tuple[str, str, str]:
|
||||
lines.append(f'당일 평가손익 : {day_pl:+,}원 ({pct:+.2f}%){suffix}')
|
||||
elif prev_key:
|
||||
lines.append(f'당일 평가손익 : 전날 스냅샷 없음 ({prev_key})')
|
||||
lines.append(f'예수금 : {d["deposit"]:,}원')
|
||||
lines.append(f'순자산 : {d["total_net"]:,}원')
|
||||
lines.append(f'보유 종목 : {len(held)}개')
|
||||
lines.append(f'당일 매매 : {len(d["traded_today"])}개')
|
||||
if d.get('traded_today') and (d.get('realized_pl_total', 0) or d.get('realized_fees_total', 0)):
|
||||
fee = d.get('realized_fees_total', 0)
|
||||
fee_part = f' (수수료·세금 {fee:,}원)' if fee else ''
|
||||
@@ -1112,7 +1185,7 @@ def build_report(by_account: bool = False) -> tuple[str, str, str]:
|
||||
tg_lines = [f'📊 주식 리포트 | {today_key} 20:00']
|
||||
tg_lines.append('')
|
||||
tg_lines.append(f'평가 {grand_value:,}원 · 예수금 {grand_deposit:,}원')
|
||||
tg_lines.append(f'순자산 {grand_net:,}원')
|
||||
tg_lines.append(f'총자산 {grand_net:,}원')
|
||||
tg_lines.append(f'{grand_profit:+,}원 ({grand_rate:+.2f}%)')
|
||||
if grand_day_pl_total is not None:
|
||||
tg_lines.append(f'당일 평가손익 {grand_day_pl_total:+,}원 ({prev_key} 대비)')
|
||||
@@ -1121,7 +1194,10 @@ def build_report(by_account: bool = False) -> tuple[str, str, str]:
|
||||
elif owner_data:
|
||||
tg_lines.append('전날 이전 스냅샷 없음')
|
||||
if grand_cash_in or grand_cash_out:
|
||||
tg_lines.append(f'당일 입출금 {grand_net_cash_flow:+,}원 (입금 {grand_cash_in:,}원 · 출금 {grand_cash_out:,}원)')
|
||||
# 한쪽만 있으면 순액과 같은 값이라 괄호 생략
|
||||
_brk = (f' (입금 {grand_cash_in:,}원 · 출금 {grand_cash_out:,}원)'
|
||||
if (grand_cash_in and grand_cash_out) else '')
|
||||
tg_lines.append(f'당일 입출금 {grand_net_cash_flow:+,}원{_brk}')
|
||||
grand_realized_pl = sum(d.get('realized_pl_total', 0) for d in owner_data.values())
|
||||
if any(d.get('traded_today') for d in owner_data.values()) and grand_realized_pl:
|
||||
tg_lines.append(f'당일 실현 {grand_realized_pl:+,}원')
|
||||
@@ -1130,7 +1206,7 @@ def build_report(by_account: bool = False) -> tuple[str, str, str]:
|
||||
for owner in ordered:
|
||||
d = owner_data[owner]
|
||||
title = OWNER_LABELS.get(owner, owner).replace(' 자산', '')
|
||||
tg_lines.append(f'• {title} 순자산 {d["total_net"]:,}원')
|
||||
tg_lines.append(f'• {title} 총자산 {d["total_net"]:,}원')
|
||||
tg_lines.append(f' 평가 {d["total_value"]:,}원 · 예수금 {d["deposit"]:,}원')
|
||||
tg_lines.append(f' {d["total_profit"]:+,}원 ({d["total_profit_rate"]:+.2f}%)')
|
||||
if d.get('day_pl_total') is not None:
|
||||
@@ -1263,9 +1339,12 @@ def main() -> int:
|
||||
args = sys.argv[1:]
|
||||
mode = 'run'
|
||||
by_account = False
|
||||
no_mail = False
|
||||
for a in args:
|
||||
if a == '--by-account':
|
||||
by_account = True
|
||||
elif a == '--no-mail':
|
||||
no_mail = True
|
||||
elif a in ('run', 'send'):
|
||||
mode = a
|
||||
else:
|
||||
@@ -1274,7 +1353,7 @@ def main() -> int:
|
||||
|
||||
print(
|
||||
f"[START] stock_portfolio_report mode={mode} by_account={by_account} "
|
||||
f"ts={started_at.isoformat()} pid={os.getpid()}"
|
||||
f"no_mail={no_mail} ts={started_at.isoformat()} pid={os.getpid()}"
|
||||
)
|
||||
|
||||
# send 모드는 휴장일/주말이면 즉시 스킵 — 시세·잔고 무변동이라 메일이 어제와 동일.
|
||||
@@ -1301,6 +1380,12 @@ def main() -> int:
|
||||
subject = f"[주식 리포트] {datetime.now(KST):%Y-%m-%d} 20:00"
|
||||
|
||||
if mode == 'send':
|
||||
# --no-mail: 스냅샷만 남기는 스케줄 실행용. 휴장일 self-skip·실패 알림은 그대로 유지해야
|
||||
# 하므로 run 모드가 아니라 send 경로에서 메일 호출만 건너뛴다.
|
||||
if no_mail:
|
||||
print('MAIL_SKIPPED=no_mail_flag')
|
||||
print(f"[END] stock_portfolio_report status=ok ts={datetime.now(KST).isoformat()}")
|
||||
return 0
|
||||
mail_ok = False
|
||||
try:
|
||||
send_result = send_mail(subject, html_body, html=True)
|
||||
|
||||
@@ -0,0 +1,597 @@
|
||||
#!/usr/bin/env python3
|
||||
"""토글 켜진 종목의 급등락 감시 → 레이 텔레그램 알림.
|
||||
|
||||
LLM을 깨우지 않음. 세 기준을 병행한다:
|
||||
1. 거래소 상·하한가 도달 — 그날 갈 수 있는 끝까지 간 것. 가장 강한 신호라 무엇에도 가리지 않는다.
|
||||
2. 거래소 VI 발동 — "이건 급등락이다"를 거래소가 공식 판정. 문턱을 우리가 정할 필요가 없다.
|
||||
3. 자기 이력 분위수 — 장중 이탈폭(전일종가 대비)이 그 종목 과거 이탈폭의 p90 을 넘으면 알림.
|
||||
|
||||
고정 %를 쓰지 않는 이유 (실측 관심·감시 65종목 × 280거래일, 2026-08-04):
|
||||
- 장중 이탈폭 중간값이 3.8% → ±3%는 급등락이 아니라 평범한 날
|
||||
- 종목별 변동성이 22배 차(ATR14 1.0%~22.3%). ±5% 문턱이면 KODEX 미국S&P500 은 발생 0회,
|
||||
SK이터닉스는 75회. 같은 5%가 한쪽엔 도달 불가, 한쪽엔 노이즈다.
|
||||
|
||||
⚠️ ATR 배수(ATR% × K)를 쓰지 않는 이유 — 2026-08-04 관리자님 지적으로 교체:
|
||||
국내 주식 하루 가격제한폭이 ±30% 인데 ATR 배수는 상한이 없어 변동성 큰 종목의 문턱이
|
||||
제한폭 밖으로 밀려난다. 실측: 1차(ATR×1.5)가 5/65 종목, 확대(ATR×3.0)가 31/65 종목(48%)에서
|
||||
30% 초과 = **영원히 발동 불가**였다(ATR% 중간값이 10%라 확대는 절반이 죽는다).
|
||||
분위수는 실제로 관측된 이탈폭이라 구조적으로 제한폭을 넘을 수 없다(p90~p99 전부 30% 초과 0종목).
|
||||
덤으로 알림량이 정의상 (1-p) 비율로 확정된다 — p90 = 종목당 연 25회.
|
||||
⚠️ 신규상장 첫날·정리매매는 제한폭 예외지만, 그런 종목은 이력이 없어 애초에 VI만 감시한다.
|
||||
|
||||
데이터원은 behive_web 의 /api/realtime/quotes — 현재가와 VI를 한 번에 주고 키움 호출이 0이다.
|
||||
VI(1h)는 시장 전역 broadcast라 구독이 필요 없고, 현재가(0B)는 구독이 필요한데 behive_web 의
|
||||
_rt_gather_codes() 가 이미 보유+관심+감시를 구독하므로 토글 종목은 그 부분집합이다.
|
||||
구독 상한에 걸려 빠진 종목만 ka10095 배치 1콜로 폴백한다.
|
||||
|
||||
⚠️ 상·하한가·VI 가 걸려 있는 동안 분위수 트리거는 **보류**했다가 풀린 뒤에 내보낸다
|
||||
(2026-08-06 관리자님 요청). 그전엔 조용히 버려져서, VI 중에 문턱을 넘었다가 해제 시점에
|
||||
되밀린 움직임은 알림이 아예 없었다. 같은 사건을 두 번 알리지 않으면서 사실은 잃지 않기 위함.
|
||||
|
||||
state:
|
||||
behive_surge_toggles.json — 감시 대상 (behive_web 의 🔔 토글이 씀)
|
||||
surge_thresholds.json — {date, by_code: {code: {thr, thr_big, prev_close, n}}}. 하루 1회만 계산
|
||||
surge_limits.json — {date, by_code: {code: {upl, lst}}}. 거래소 상·하한가, 하루 1콜
|
||||
surge_alerts.json — {date: {code: [트리거키], __pending__: {code: {키: 보류정보}}}}
|
||||
방향별 1회 + 확대단계 1회 + 상·하한가 1회, VI 는 발동 건마다 1회
|
||||
|
||||
Usage:
|
||||
python3 surge_monitor.py check # 1회 감시 (launchd 용)
|
||||
python3 surge_monitor.py check --force # 장외에도 실행 (테스트용)
|
||||
python3 surge_monitor.py dry-run # 판정만 출력, 텔레그램 발송 없음
|
||||
python3 surge_monitor.py list # 토글 종목의 문턱(%)·발동가 출력
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
sys.path.insert(0, str(WORKSPACE))
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
|
||||
STATE_DIR = WORKSPACE / 'state'
|
||||
TOGGLES = STATE_DIR / 'behive_surge_toggles.json'
|
||||
THR_CACHE = STATE_DIR / 'surge_thresholds.json'
|
||||
LIMITS_CACHE = STATE_DIR / 'surge_limits.json'
|
||||
ALERTS_STATE = STATE_DIR / 'surge_alerts.json'
|
||||
# 보류 트리거를 담는 예약 키. 종목코드는 6자리라 이 이름과 충돌하지 않는다.
|
||||
PENDING_KEY = '__pending__'
|
||||
CONFIG_PATH = Path('/Users/snowoyh/.openclaw/openclaw.json')
|
||||
|
||||
TELEGRAM_ACCOUNT = 'stock' # 레이 봇
|
||||
|
||||
# 1차 문턱 = 그 종목 과거 장중 이탈폭의 이 분위수. 통수를 정하는 유일한 손잡이 —
|
||||
# 분위수라 알림량이 정의상 (1-p) 비율로 확정된다. 0.90 = 종목당 연 25회(≈2주 1번).
|
||||
SURGE_PCTL = 0.90
|
||||
# 확대 단계 — 1차 알림 후 여기까지 더 벌어지면 한 번 더. 종목당 하루 최대 2통.
|
||||
SURGE_PCTL_BIG = 0.98
|
||||
# 분위수 산출에 필요한 최소 관측일. 미달(신규상장 등)이면 VI만 감시한다.
|
||||
MIN_HISTORY = 60
|
||||
# 이력 조회 봉 수 — sqlite 캐시에서 읽는 양만 늘린다(추가 API 콜 없음).
|
||||
HISTORY_COUNT = 400
|
||||
# 문턱 산출 페이싱 — ka10081 유량이 초당 5건이라 캐시가 stale 한 종목이 많으면 429가 쏟아진다.
|
||||
MAX_COMPUTE_PER_CYCLE = 12
|
||||
COMPUTE_PACE_SEC = 0.35
|
||||
|
||||
# behive_web 실시간 엔드포인트. BIND_HOST='' 라 localhost 로 도달한다.
|
||||
RT_URL = 'http://127.0.0.1:18790/api/realtime/quotes'
|
||||
RT_TIMEOUT = 5
|
||||
|
||||
MARKET_OPEN = (9, 0)
|
||||
# 20:00 = NXT 애프터마켓 + KRX 애프터마켓(2026-09-14 시행) 마감.
|
||||
# ⚠️ 마감 후 버퍼를 두지 않는다 — 데이터원인 realtime_hub 가 20:00 에 종료되므로
|
||||
# (`_market_open()` → `behive_web._market_phase_state()['active']`) 그 뒤 사이클은
|
||||
# 시세·VI 를 못 받고 헛돈다. plist 도 19:59 까지만 발화한다.
|
||||
# ⚠️ 확대가 성립하는 전제는 hub 구독이 `_AL`(통합) 이라는 것 — KRX 애프터마켓 체결이
|
||||
# 그 구독으로 들어온다. bare 코드면 15:30 이후 시세가 끊겨 감시가 무의미하다.
|
||||
MARKET_CLOSE = (20, 0)
|
||||
|
||||
|
||||
def load_json(path: Path, default):
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except Exception:
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def save_json(path: Path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + '.tmp')
|
||||
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2))
|
||||
tmp.replace(path)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def alerts_lock():
|
||||
"""surge_alerts.json 동시 쓰기 직렬화. watchlist_monitor 와 동일 패턴, 별도 lock 파일."""
|
||||
lock_path = ALERTS_STATE.with_suffix(ALERTS_STATE.suffix + '.lock')
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
f = open(lock_path, 'a')
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
f.close()
|
||||
|
||||
|
||||
def is_market_hours() -> bool:
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False
|
||||
# KRX 휴장일도 거래 없음. 데이터 파일 누락이나 import 실패 시엔 평소대로 진행.
|
||||
try:
|
||||
from holiday_sync import is_holiday_today
|
||||
if is_holiday_today():
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
mins = now.hour * 60 + now.minute
|
||||
return (MARKET_OPEN[0] * 60 + MARKET_OPEN[1]) <= mins <= (MARKET_CLOSE[0] * 60 + MARKET_CLOSE[1])
|
||||
|
||||
|
||||
def send_telegram(text: str) -> bool:
|
||||
cfg = json.loads(CONFIG_PATH.read_text())
|
||||
acct = cfg['channels']['telegram']['accounts'][TELEGRAM_ACCOUNT]
|
||||
token = acct['botToken']
|
||||
chat_ids = acct.get('allowFrom') or []
|
||||
if not chat_ids:
|
||||
print('no telegram chat_ids', file=sys.stderr)
|
||||
return False
|
||||
url = f'https://api.telegram.org/bot{token}/sendMessage'
|
||||
ok = True
|
||||
for chat_id in chat_ids:
|
||||
data = urllib.parse.urlencode({
|
||||
'chat_id': chat_id,
|
||||
'text': text[:4000],
|
||||
'disable_web_page_preview': 'true',
|
||||
}).encode()
|
||||
try:
|
||||
req = urllib.request.Request(url, data=data, method='POST')
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
if r.status != 200:
|
||||
ok = False
|
||||
print(f'telegram HTTP {r.status}', file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f'telegram error: {e}', file=sys.stderr)
|
||||
ok = False
|
||||
return ok
|
||||
|
||||
|
||||
# ---------------- 감시 대상 ----------------
|
||||
def load_toggles() -> dict[str, str]:
|
||||
"""{code: name} — behive_web 의 🔔 토글이 켠 종목만."""
|
||||
raw = load_json(TOGGLES, {})
|
||||
by_code = raw.get('by_code') if isinstance(raw, dict) else None
|
||||
if not isinstance(by_code, dict):
|
||||
return {}
|
||||
out: dict[str, str] = {}
|
||||
for code, v in by_code.items():
|
||||
cc = kc._clean_code(code)
|
||||
if not cc or not v:
|
||||
continue
|
||||
# v 는 True(레거시) 또는 {'name': ...}
|
||||
out[cc] = (v.get('name') or '') if isinstance(v, dict) else ''
|
||||
return out
|
||||
|
||||
|
||||
# ---------------- 데이터원 ----------------
|
||||
def fetch_realtime(codes: list[str]) -> tuple[dict, dict, bool]:
|
||||
"""behive_web 실시간 허브에서 (quotes, vi, ok). 키움 호출 0.
|
||||
|
||||
허브가 콜드하거나 behive_web 이 죽어 있으면 ok=False — 호출측이 폴백/경고를 결정한다.
|
||||
"""
|
||||
if not codes:
|
||||
return {}, {}, False
|
||||
url = f'{RT_URL}?codes={",".join(codes)}'
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=RT_TIMEOUT) as r:
|
||||
d = json.loads(r.read().decode())
|
||||
except Exception as e:
|
||||
print(f'[warn] behive_web 실시간 조회 실패 ({e}) — VI 감시 불가, ATR만 진행', file=sys.stderr)
|
||||
return {}, {}, False
|
||||
return (d.get('quotes') or {}), (d.get('vi') or {}), bool(d.get('connected'))
|
||||
|
||||
|
||||
def fetch_quotes_fallback(codes: list[str]) -> dict:
|
||||
"""구독 상한에 걸려 허브에 없는 종목만 ka10095 배치 1콜로 보충."""
|
||||
if not codes:
|
||||
return {}
|
||||
try:
|
||||
return kc.get_watchlist_quotes(codes) or {}
|
||||
except Exception as e:
|
||||
print(f'[warn] ka10095 배치 실패: {e}', file=sys.stderr)
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------- 문턱 (자기 이력 분위수) ----------------
|
||||
def _quantile(sorted_vals: list[float], p: float) -> float:
|
||||
"""오름차순 리스트의 p 분위수. 실측 스크립트와 같은 식(nearest-rank)을 써야 값이 일치한다."""
|
||||
return sorted_vals[min(len(sorted_vals) - 1, int(len(sorted_vals) * p))]
|
||||
|
||||
|
||||
def _compute_thresholds(code: str) -> tuple[dict | None, bool]:
|
||||
"""(문턱 dict 또는 None, 재시도해야 하는가).
|
||||
|
||||
장중 이탈폭 = max(|고가−전일종가|, |저가−전일종가|) / 전일종가 × 100.
|
||||
종가 대비가 아니라 **전일종가 대비 장중 최대 이탈폭**을 쓰는 이유 — 실시간 감시가 보는 값이
|
||||
`현재가 vs 전일종가`(키움 pct)라서 같은 축으로 비교해야 한다. 종가기준은 장중 움직임을
|
||||
과소평가해(중간값 3.8% vs 종가기준 1.x%) 문턱이 너무 낮게 잡힌다.
|
||||
|
||||
⚠️ 두 번째 반환값이 필요한 이유 — 조회 실패(429·네트워크)를 이력부족과 같이 취급해
|
||||
빈 dict 로 캐시하면 그 종목이 **하루 내내 조용히 VI만 감시**하게 된다. 실패는 재시도 대상.
|
||||
"""
|
||||
try:
|
||||
import daily_candles_cache as dcc
|
||||
candles = dcc.get_candles(code, HISTORY_COUNT)
|
||||
except Exception as e:
|
||||
print(f'[{code}] 일봉 조회 실패 (다음 사이클 재시도): {e}', file=sys.stderr)
|
||||
return None, True
|
||||
if not candles or len(candles) < MIN_HISTORY:
|
||||
return None, False
|
||||
exc: list[float] = []
|
||||
for i in range(1, len(candles)):
|
||||
pc = candles[i - 1]['close']
|
||||
if not pc:
|
||||
continue
|
||||
exc.append(max(abs(candles[i]['high'] - pc), abs(candles[i]['low'] - pc)) / pc * 100)
|
||||
if len(exc) < MIN_HISTORY:
|
||||
return None, False
|
||||
exc.sort()
|
||||
prev_close = candles[-1]['close']
|
||||
if not prev_close:
|
||||
return None, False
|
||||
return {
|
||||
'thr': round(_quantile(exc, SURGE_PCTL), 2),
|
||||
'thr_big': round(_quantile(exc, SURGE_PCTL_BIG), 2),
|
||||
'prev_close': prev_close,
|
||||
'n': len(exc),
|
||||
}, False
|
||||
|
||||
|
||||
def get_threshold_map(codes: list[str]) -> dict[str, dict]:
|
||||
"""종목별 문턱 맵. 하루 1회만 계산하고 캐시한다.
|
||||
|
||||
⚠️ daily_candles_cache.get_candles 는 캐시 최신봉이 어제보다 오래되면 ka10081 을 때린다.
|
||||
매 사이클(1분) × 종목수만큼 호출하면 폭주하므로 날짜가 바뀔 때와 새 토글이 생길 때만 계산.
|
||||
⚠️ 분위수 파라미터를 바꿨으면 캐시가 옛 값을 들고 있으니 `pctl` 서명이 다르면 재계산한다.
|
||||
⚠️ **ka10081 유량 제한이 초당 5건**이라 종목을 한꺼번에 돌리면 429가 쏟아진다(2026-08-04 실측:
|
||||
70종목 일괄 산출 시 26종목 실패). 그래서 사이클당 `MAX_COMPUTE_PER_CYCLE` 개까지만,
|
||||
`COMPUTE_PACE_SEC` 간격으로 계산한다. 남은 종목은 다음 사이클이 이어받는다(1분 간격 × 396회라
|
||||
장 시작 몇 분 안에 전부 채워진다). 조회 실패는 캐시하지 않아 다음 사이클에 재시도된다.
|
||||
"""
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
sig = f'{SURGE_PCTL}/{SURGE_PCTL_BIG}'
|
||||
cache = load_json(THR_CACHE, {})
|
||||
fresh = cache.get('date') == today and cache.get('pctl') == sig
|
||||
by_code = cache.get('by_code') if fresh else {}
|
||||
if not isinstance(by_code, dict):
|
||||
by_code = {}
|
||||
missing = [c for c in codes if c not in by_code][:MAX_COMPUTE_PER_CYCLE]
|
||||
if missing:
|
||||
for i, c in enumerate(missing):
|
||||
if i:
|
||||
time.sleep(COMPUTE_PACE_SEC)
|
||||
r, retry = _compute_thresholds(c)
|
||||
if retry:
|
||||
continue # 실패는 기록하지 않는다 — 다음 사이클에 다시 시도
|
||||
by_code[c] = r if r else {}
|
||||
save_json(THR_CACHE, {'date': today, 'pctl': sig, 'by_code': by_code})
|
||||
return {c: v for c, v in by_code.items() if v.get('thr')}
|
||||
|
||||
|
||||
# ---------------- 상·하한가 (거래소 계산값) ----------------
|
||||
def get_limit_map(codes: list[str]) -> dict[str, dict]:
|
||||
"""{code: {'upl', 'lst'}} — 거래소가 계산한 가격제한폭. 하루 1콜(ka10095 배치).
|
||||
|
||||
기준가(전일종가)로 정해져 장중 불변이라 하루 1회면 충분하다. 분위수 문턱과 달리
|
||||
일봉 이력이 필요 없어서 신규상장 등 이력 부족 종목도 이 기준으론 감시된다.
|
||||
⚠️ 배치 호출 자체가 실패하면 캐시하지 않는다 — 문턱 캐시와 같은 이유로, 실패를
|
||||
'제한가 없음'으로 굳히면 그 종목이 하루 내내 조용히 빠진다. 응답에 값이 없는
|
||||
종목만 빈 dict 로 굳혀 매 사이클 재조회를 막는다.
|
||||
"""
|
||||
if not codes:
|
||||
return {}
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
cache = load_json(LIMITS_CACHE, {})
|
||||
by_code = cache.get('by_code') if cache.get('date') == today else {}
|
||||
if not isinstance(by_code, dict):
|
||||
by_code = {}
|
||||
missing = [c for c in codes if c not in by_code]
|
||||
if missing:
|
||||
try:
|
||||
rows = kc.get_watchlist_quotes(missing) or {}
|
||||
except Exception as e:
|
||||
print(f'[warn] 상하한가 조회 실패 (다음 사이클 재시도): {e}', file=sys.stderr)
|
||||
return {c: v for c, v in by_code.items() if v.get('upl')}
|
||||
for c in missing:
|
||||
r = rows.get(c) or {}
|
||||
upl, lst = r.get('upper_limit') or 0, r.get('lower_limit') or 0
|
||||
by_code[c] = {'upl': upl, 'lst': lst} if (upl and lst) else {}
|
||||
save_json(LIMITS_CACHE, {'date': today, 'by_code': by_code})
|
||||
return {c: v for c, v in by_code.items() if v.get('upl')}
|
||||
|
||||
|
||||
# ---------------- 판정 ----------------
|
||||
def _limit_hit(price: int, limits: dict | None) -> str | None:
|
||||
"""'up'(상한가) | 'down'(하한가) | None. 거래소 제한가와 현재가 비교."""
|
||||
if not limits or not price:
|
||||
return None
|
||||
upl, lst = limits.get('upl') or 0, limits.get('lst') or 0
|
||||
if upl and price >= upl:
|
||||
return 'up'
|
||||
if lst and price <= lst:
|
||||
return 'down'
|
||||
return None
|
||||
|
||||
|
||||
def _quantile_triggers(pct: float, thr: dict | None) -> list[dict]:
|
||||
"""분위수 트리거만 — [{key, reason, word}]."""
|
||||
if not thr or not thr.get('thr'):
|
||||
return []
|
||||
direction = 'up' if pct > 0 else 'down'
|
||||
word = '급등' if pct > 0 else '급락'
|
||||
t1, t2 = thr['thr'], thr.get('thr_big') or 0
|
||||
top1 = round((1 - SURGE_PCTL) * 100)
|
||||
top2 = round((1 - SURGE_PCTL_BIG) * 100)
|
||||
out: list[dict] = []
|
||||
if t2 and abs(pct) >= t2:
|
||||
out.append({'key': f'{direction}2', 'word': word,
|
||||
'reason': f'{thr["n"]}일 중 상위 {top2}% 움직임 (문턱 {t2:.1f}%)'})
|
||||
if abs(pct) >= t1:
|
||||
out.append({'key': direction, 'word': word,
|
||||
'reason': f'{thr["n"]}일 중 상위 {top1}% 움직임 (문턱 {t1:.1f}%)'})
|
||||
return out
|
||||
|
||||
|
||||
def evaluate(pct: float, price: int, thr: dict | None, vi: dict | None,
|
||||
limits: dict | None) -> tuple[list[dict], list[dict], str]:
|
||||
"""(즉시 알릴 트리거, 보류할 트리거, 가림사유).
|
||||
|
||||
거래소 판정(상·하한가·VI)이 걸려 있는 동안 분위수 트리거는 **보류**한다. 같은 사건을
|
||||
두 번 알리지 않으면서도, 그 사이 문턱을 넘은 사실은 버리지 않고 풀린 뒤에 내보내기 위해서다.
|
||||
가림사유가 빈 문자열이면 가린 것이 없다는 뜻 — 호출측은 이때 보류분을 방출한다.
|
||||
⚠️ 가림 여부는 `defer` 가 비었는지로 판단하면 안 된다. VI 중에 주가가 문턱 아래로
|
||||
되밀리면 defer 도 비는데, 그걸 '가림 해제'로 읽으면 VI 도중에 보류분이 새어나간다.
|
||||
"""
|
||||
now: list[dict] = []
|
||||
hit = _limit_hit(price, limits)
|
||||
if hit == 'up':
|
||||
now.append({'key': 'limit_up', 'word': '상한가',
|
||||
'reason': f'거래소 상한가 도달 ({(limits or {}).get("upl", 0):,}원)'})
|
||||
elif hit == 'down':
|
||||
now.append({'key': 'limit_down', 'word': '하한가',
|
||||
'reason': f'거래소 하한가 도달 ({(limits or {}).get("lst", 0):,}원)'})
|
||||
vi_on = bool(vi and vi.get('active'))
|
||||
if vi_on:
|
||||
t = (vi.get('trigger_time') or '').strip() or 'na'
|
||||
kind = (vi.get('apply_kind') or '').strip() or 'VI'
|
||||
now.append({'key': f'vi:{t}', 'word': None, 'reason': f'거래소 VI 발동 ({kind})'})
|
||||
quantile = _quantile_triggers(pct, thr)
|
||||
if hit or vi_on:
|
||||
return now, quantile, ({'up': '상한가', 'down': '하한가'}.get(hit) or 'VI')
|
||||
return now + quantile, [], ''
|
||||
|
||||
|
||||
def build_message(records: list[dict]) -> str:
|
||||
ts = datetime.now(KST).strftime('%m/%d %H:%M')
|
||||
lines = [f'[급등락] {ts}', f'{len(records)}건 감지']
|
||||
for r in records:
|
||||
pct = r['pct']
|
||||
# ⚠️ 아이콘은 pct 가 아니라 방향어에서 파생한다. 보류 방출 건은 돌파 시점 방향(급락)과
|
||||
# 방출 시점 현재가 부호(+)가 어긋날 수 있어, pct 로 고르면 '🚀 급락'이 찍힌다.
|
||||
word = r.get('word') or ('급등' if pct > 0 else '급락')
|
||||
icon = {'상한가': '⏫', '하한가': '⏬', '급등': '🚀', '급락': '🔻'}.get(word, '🚀')
|
||||
label = r['name'] or r['code']
|
||||
lines.append('')
|
||||
lines.append(f'{icon} #{label} {word} ({pct:+.2f}%)')
|
||||
lines.append(f'• 현재가: {r["price"]:,}원')
|
||||
lines.append(f'• 사유: {r["reason"]}')
|
||||
if r.get('vi_price'):
|
||||
lines.append(f'• VI 발동가: {r["vi_price"]:,}원')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ---------------- 실행 ----------------
|
||||
def _prune(state: dict, today: str) -> dict:
|
||||
"""오늘·어제만 남긴다 (무한 증식 방지)."""
|
||||
keep = {today, (datetime.now(KST) - timedelta(days=1)).strftime('%Y-%m-%d')}
|
||||
return {k: v for k, v in state.items() if k in keep}
|
||||
|
||||
|
||||
def run(dry: bool = False, force: bool = False) -> int:
|
||||
if not force and not is_market_hours():
|
||||
print(f'장외 시간 — skip ({datetime.now(KST).strftime("%Y-%m-%d %H:%M")})')
|
||||
return 0
|
||||
toggles = load_toggles()
|
||||
if not toggles:
|
||||
print('감시 대상 없음 — 자산웹에서 🔔 토글을 켜주세요')
|
||||
return 0
|
||||
codes = sorted(toggles)
|
||||
quotes, vi_map, connected = fetch_realtime(codes)
|
||||
missing = [c for c in codes if not (quotes.get(c) or {}).get('price')]
|
||||
if missing:
|
||||
for c, q in fetch_quotes_fallback(missing).items():
|
||||
quotes[kc._clean_code(c)] = {'price': q.get('price'), 'pct': q.get('change_pct'), 'change': q.get('change')}
|
||||
thr_map = get_threshold_map(codes)
|
||||
limit_map = get_limit_map(codes)
|
||||
|
||||
today = datetime.now(KST).strftime('%Y-%m-%d')
|
||||
day_state = load_json(ALERTS_STATE, {}).get(today) or {}
|
||||
already = {f'{c}:{k}' for c, ks in day_state.items() if isinstance(ks, list) for k in ks}
|
||||
# 보류분 — 감시에서 빠진 종목 것은 버린다(토글이 꺼졌으면 방출할 이유가 없다).
|
||||
held = day_state.get(PENDING_KEY) or {}
|
||||
pending = {c: v for c, v in held.items() if c in toggles and isinstance(v, dict)} if isinstance(held, dict) else {}
|
||||
pending_sig = json.dumps(pending, sort_keys=True)
|
||||
|
||||
to_send: list[dict] = []
|
||||
flushed: list[tuple[str, str]] = [] # (code, key) — 발송 성공 시에만 보류에서 지운다
|
||||
|
||||
def _rec(code: str, price: int, pct: float, trig: dict, vi: dict | None) -> dict:
|
||||
r = {
|
||||
'code': code,
|
||||
'name': toggles.get(code) or '',
|
||||
'price': price,
|
||||
'pct': pct,
|
||||
'key': trig['key'],
|
||||
'word': trig.get('word'),
|
||||
'reason': trig['reason'],
|
||||
}
|
||||
if vi and vi.get('active'):
|
||||
r['vi_price'] = vi.get('trigger_price') or 0
|
||||
return r
|
||||
|
||||
for code in codes:
|
||||
q = quotes.get(code) or {}
|
||||
if not q.get('price') or q.get('pct') is None:
|
||||
continue
|
||||
price, pct = int(q['price']), float(q['pct'])
|
||||
vi = vi_map.get(code)
|
||||
now_trigs, defer_trigs, blocked_by = evaluate(
|
||||
pct, price, thr_map.get(code), vi, limit_map.get(code))
|
||||
for trig in now_trigs:
|
||||
if f'{code}:{trig["key"]}' in already:
|
||||
continue
|
||||
to_send.append(_rec(code, price, pct, trig, vi))
|
||||
already.add(f'{code}:{trig["key"]}')
|
||||
if blocked_by:
|
||||
# 가림 중 — 문턱 돌파 사실만 적어둔다. 이탈폭이 가장 컸던 시점을 남긴다.
|
||||
for trig in defer_trigs:
|
||||
if f'{code}:{trig["key"]}' in already:
|
||||
continue
|
||||
cur = (pending.get(code) or {}).get(trig['key']) or {}
|
||||
if abs(pct) > abs(cur.get('pct') or 0):
|
||||
pending.setdefault(code, {})[trig['key']] = {
|
||||
'pct': pct, 'word': trig.get('word'),
|
||||
'reason': trig['reason'], 'via': blocked_by,
|
||||
}
|
||||
continue
|
||||
# 가림 해제 — 보류분 방출. 같은 키를 위에서 이미 즉시 알렸으면 already 가 걸러낸다.
|
||||
for key, d in sorted((pending.get(code) or {}).items()):
|
||||
if f'{code}:{key}' in already:
|
||||
flushed.append((code, key))
|
||||
continue
|
||||
trig = {
|
||||
'key': key, 'word': d.get('word'),
|
||||
'reason': f'{d.get("reason") or "문턱 돌파"} — {d.get("via") or "VI"} 중 최대 {d.get("pct", 0):+.2f}% 도달',
|
||||
}
|
||||
to_send.append(_rec(code, price, pct, trig, vi))
|
||||
already.add(f'{code}:{key}')
|
||||
flushed.append((code, key))
|
||||
|
||||
if dry:
|
||||
print(f'감시 {len(codes)}종목 / 허브연결 {connected} / 문턱 {len(thr_map)}종목 / 제한가 {len(limit_map)}종목')
|
||||
for code in codes:
|
||||
q = quotes.get(code) or {}
|
||||
t = thr_map.get(code) or {}
|
||||
lm = limit_map.get(code) or {}
|
||||
thr = f'{t["thr"]:.1f}% / 확대 {t["thr_big"]:.1f}%' if t.get('thr') else '— (이력부족, VI·제한가만)'
|
||||
lim = f'{lm["upl"]:,}/{lm["lst"]:,}' if lm.get('upl') else '—'
|
||||
print(f' {code} {toggles.get(code) or "":<12} 등락 {q.get("pct")}% / 문턱 {thr} / 상하한 {lim}'
|
||||
f'{" / VI" if (vi_map.get(code) or {}).get("active") else ""}'
|
||||
f'{" / 보류 " + ",".join(sorted(pending[code])) if pending.get(code) else ""}')
|
||||
if to_send:
|
||||
print('--- DRY ---')
|
||||
print(build_message(to_send))
|
||||
print(f'done. watched={len(codes)} triggered={len(to_send)} pending={sum(len(v) for v in pending.values())}')
|
||||
return 0
|
||||
|
||||
ok = send_telegram(build_message(to_send)) if to_send else True
|
||||
if ok:
|
||||
for code, key in flushed:
|
||||
bucket = pending.get(code) or {}
|
||||
bucket.pop(key, None)
|
||||
if not bucket:
|
||||
pending.pop(code, None)
|
||||
if not to_send and json.dumps(pending, sort_keys=True) == pending_sig:
|
||||
print(f'done. watched={len(codes)} triggered=0')
|
||||
return 0
|
||||
|
||||
with alerts_lock():
|
||||
latest = _prune(load_json(ALERTS_STATE, {}), today)
|
||||
day = latest.setdefault(today, {})
|
||||
if ok:
|
||||
for r in to_send:
|
||||
bucket = day.setdefault(r['code'], [])
|
||||
if r['key'] not in bucket:
|
||||
bucket.append(r['key'])
|
||||
print(f'alerted {r["code"]} {r["name"]}:{r["key"]} {r["pct"]:+.2f}% @ {r["price"]:,}원')
|
||||
if pending:
|
||||
day[PENDING_KEY] = pending
|
||||
else:
|
||||
day.pop(PENDING_KEY, None)
|
||||
save_json(ALERTS_STATE, latest)
|
||||
print(f'done. watched={len(codes)} triggered={len(to_send)} pending={sum(len(v) for v in pending.values())}')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
toggles = load_toggles()
|
||||
if not toggles:
|
||||
print('감시 대상 없음 — 자산웹에서 🔔 토글을 켜주세요')
|
||||
return 0
|
||||
codes = sorted(toggles)
|
||||
thr_map = get_threshold_map(codes)
|
||||
limit_map = get_limit_map(codes)
|
||||
top1 = round((1 - SURGE_PCTL) * 100)
|
||||
top2 = round((1 - SURGE_PCTL_BIG) * 100)
|
||||
print(f'감시 {len(codes)}종목 — 1차=자기이력 상위 {top1}% / 확대=상위 {top2}%')
|
||||
print(f'{"종목":<16}{"전일종가":>10}{"1차":>7}{"급등가":>10}{"급락가":>10}{"확대":>7}'
|
||||
f'{"상한가":>10}{"하한가":>10}')
|
||||
for c in codes:
|
||||
t = thr_map.get(c) or {}
|
||||
lm = limit_map.get(c) or {}
|
||||
name = (toggles[c] or c)[:15]
|
||||
lim = f'{lm["upl"]:>10,}{lm["lst"]:>10,}' if lm.get('upl') else f'{"—":>10}{"—":>10}'
|
||||
if not t.get('thr'):
|
||||
print(f'{name:<16}{"이력 부족 — VI·제한가만":>30}{lim}')
|
||||
continue
|
||||
pv, t1, t2 = t['prev_close'], t['thr'], t['thr_big']
|
||||
print(f'{name:<16}{pv:>10,}{t1:>6.1f}%{round(pv * (1 + t1 / 100)):>10,}'
|
||||
f'{round(pv * (1 - t1 / 100)):>10,}{t2:>6.1f}%{lim}')
|
||||
print(f'\n※ 상·하한가 도달은 문턱과 별개로 항상 알림 (거래소 계산값, 하루 1콜 캐시)')
|
||||
print(f'※ VI 발동은 이 문턱과 별개로 먼저 알림 (정적VI 전일종가 ±10% 부근)')
|
||||
print(f'※ 상·하한가·VI 중 문턱을 넘으면 보류했다가 풀린 뒤 알림 (사실을 버리지 않음)')
|
||||
print(f'※ 문턱은 하루 가격제한폭 ±30% 안에 있음이 보장됨 (실제 관측된 이탈폭의 분위수)')
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.argv[1] if len(sys.argv) > 1 else 'help'
|
||||
force = '--force' in sys.argv[2:]
|
||||
try:
|
||||
if cmd == 'check':
|
||||
return run(dry=False, force=force)
|
||||
if cmd == 'dry-run':
|
||||
return run(dry=True, force=True)
|
||||
if cmd == 'list':
|
||||
return cmd_list()
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
except Exception as e:
|
||||
import traceback
|
||||
tb = traceback.format_exc()
|
||||
print(f'[fatal] {e}\n{tb}', file=sys.stderr)
|
||||
try:
|
||||
send_telegram(f'⚠️ [surge_monitor] 실행 실패\n{type(e).__name__}: {e}')
|
||||
except Exception:
|
||||
pass
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,68 @@
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import ipo_alert
|
||||
|
||||
|
||||
class IpoAlertTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.row = {
|
||||
'ipoCode': 'A250030',
|
||||
'compName': '진코스텍',
|
||||
'poStartDate': '2026-10-02',
|
||||
'poEndDate': '2026-10-06',
|
||||
'lcalDate': '2026-10-15',
|
||||
'fixPubPrice': '23500',
|
||||
'orgNm': '하나증권',
|
||||
}
|
||||
self.detail = {
|
||||
'ipoInfo': {
|
||||
'compName': '진코스텍',
|
||||
'poStartDate': '2026-10-02',
|
||||
'poEndDate': '2026-10-06',
|
||||
'lcalDate': '2026-10-15',
|
||||
},
|
||||
'registrationStatement': {'sbscMrgnRatio': '50.00'},
|
||||
'demandForecastingResult': {'fixPubPrice': '23500'},
|
||||
'joinManagers': [{'orgNm': '하나증권', 'sbscMinNum': '10'}],
|
||||
}
|
||||
|
||||
def test_subscription_format_uses_first_day_and_minimum_cash(self):
|
||||
text = ipo_alert.format_alert('subscription', self.row, self.detail)
|
||||
self.assertIn('종목명 : 진코스텍', text)
|
||||
self.assertIn('증권사 : 하나증권', text)
|
||||
self.assertIn('청약일 : 2026-10-02 ~ 2026-10-06', text)
|
||||
self.assertIn('증거금 : 117,500원(최소 10주)', text)
|
||||
self.assertNotIn('23,500원', text)
|
||||
self.assertNotIn('50%', text)
|
||||
self.assertIn('수수료 2,000원(온라인 일반 기준)', text)
|
||||
|
||||
def test_listing_uses_listing_date(self):
|
||||
text = ipo_alert.format_alert('listing', self.row, self.detail)
|
||||
self.assertIn('[신규 상장]', text)
|
||||
self.assertIn('상장일 : 2026-10-15', text)
|
||||
|
||||
def test_collect_subscription_matches_only_first_day(self):
|
||||
payload = {
|
||||
'examinationList': [], 'demandForecastingList': [],
|
||||
'forecastingCompleteList': [],
|
||||
'subscriptionList': [self.row],
|
||||
'subscriptionCompleteList': [], 'listingList': [],
|
||||
}
|
||||
with patch.object(ipo_alert, 'fetch', return_value=ipo_alert.json.dumps(payload)), patch.object(
|
||||
ipo_alert, '_detail', return_value=self.detail
|
||||
):
|
||||
first = ipo_alert.collect_for_date('subscription', date(2026, 10, 2))
|
||||
second = ipo_alert.collect_for_date('subscription', date(2026, 10, 3))
|
||||
self.assertEqual(len(first), 1)
|
||||
self.assertEqual(second, [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,160 @@
|
||||
import sys
|
||||
import unittest
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import ipo_calendar_sync as sync
|
||||
|
||||
|
||||
class IpoParserTests(unittest.TestCase):
|
||||
def test_naver_api_parser_includes_cutoff_date(self):
|
||||
payload = {
|
||||
'examinationList': [],
|
||||
'demandForecastingList': [],
|
||||
'forecastingCompleteList': [],
|
||||
'subscriptionList': [{
|
||||
'ipoCode': 'A0035S0',
|
||||
'compName': '빅웨이브로보틱스',
|
||||
'poStartDate': '2026-09-15',
|
||||
'poEndDate': '2026-09-16',
|
||||
'lcalDate': '2026-09-29',
|
||||
'orgNm': '한국투자증권',
|
||||
}],
|
||||
'subscriptionCompleteList': [],
|
||||
'listingList': [],
|
||||
}
|
||||
with patch.object(sync, 'fetch', return_value=sync.json.dumps(payload)):
|
||||
events = sync.parse_naver_api_events_from(date(2026, 9, 29))
|
||||
|
||||
self.assertEqual([(e.kind, e.name) for e in events], [
|
||||
('listing', '빅웨이브로보틱스'),
|
||||
])
|
||||
self.assertEqual(events[0].start_date, date(2026, 9, 29))
|
||||
|
||||
def test_38_parsers_read_subscription_and_listing_tables(self):
|
||||
subscription_html = '''
|
||||
<table summary="공모주 청약일정"><tbody>
|
||||
<tr><th>종목명</th><th>공모주일정</th><th>확정공모가</th><th>희망공모가</th><th>경쟁률</th><th>주간사</th></tr>
|
||||
<tr><td>넥스아이</td><td>2026.11.03~11.04</td><td>-</td><td>-</td><td></td><td>한국투자증권</td></tr>
|
||||
</tbody></table>'''
|
||||
listing_html = '''
|
||||
<table summary="신규상장종목"><tbody>
|
||||
<tr><th>기업명</th><th>신규상장일</th></tr>
|
||||
<tr><td>빅웨이브로보틱스</td><td>2026/09/29</td></tr>
|
||||
</tbody></table>'''
|
||||
|
||||
subscriptions = sync.parse_38_subscription_html(
|
||||
subscription_html, date(2026, 9, 29)
|
||||
)
|
||||
listings = sync.parse_38_listing_html(listing_html, date(2026, 9, 29))
|
||||
|
||||
self.assertEqual(subscriptions[0].start_date, date(2026, 11, 3))
|
||||
self.assertEqual(subscriptions[0].brokers, '한국투자증권')
|
||||
self.assertEqual(listings[0].name, '빅웨이브로보틱스')
|
||||
self.assertEqual(listings[0].start_date, date(2026, 9, 29))
|
||||
|
||||
def test_zero_primary_result_switches_to_38_fallback(self):
|
||||
fallback = sync.EventSpec(
|
||||
'listing', '빅웨이브로보틱스', date(2026, 9, 29),
|
||||
date(2026, 9, 29), '', sync.LISTING_URL,
|
||||
source_label='38커뮤니케이션 IPO',
|
||||
)
|
||||
with patch.object(sync, 'parse_naver_api_events_from', return_value=[]), patch.object(
|
||||
sync, 'parse_38_events_from', return_value=[fallback]
|
||||
):
|
||||
events, parser_used, errors = sync.collect_ipo_events(date(2026, 9, 29))
|
||||
|
||||
self.assertEqual(parser_used, '38_fallback')
|
||||
self.assertEqual(events, [fallback])
|
||||
self.assertEqual(errors, ['naver_api: 수집 결과 0건'])
|
||||
|
||||
def test_all_parser_failures_raise_structured_error(self):
|
||||
with patch.object(
|
||||
sync, 'parse_naver_api_events_from', side_effect=ValueError('schema changed')
|
||||
), patch.object(
|
||||
sync, 'parse_38_events_from', side_effect=ValueError('table changed')
|
||||
):
|
||||
with self.assertRaises(sync.IpoParserError) as caught:
|
||||
sync.collect_ipo_events(date(2026, 9, 29))
|
||||
|
||||
self.assertEqual(caught.exception.errors, [
|
||||
'naver_api: schema changed',
|
||||
'38_fallback: table changed',
|
||||
])
|
||||
|
||||
def test_parser_alert_is_deduplicated_and_recovery_is_reported(self):
|
||||
state = {}
|
||||
errors = ['naver_api: 수집 결과 0건']
|
||||
with patch.object(sync, 'send_telegram', return_value=True) as send:
|
||||
sync.update_parser_health(state, 'degraded', errors, 12)
|
||||
sync.update_parser_health(state, 'degraded', errors, 12)
|
||||
sync.update_parser_health(state, 'healthy', [], 18)
|
||||
|
||||
self.assertEqual(send.call_count, 2)
|
||||
self.assertIn('파서 일부 실패', send.call_args_list[0].args[0])
|
||||
self.assertIn('파서 복구', send.call_args_list[1].args[0])
|
||||
self.assertEqual(state['parser_alerted_status'], 'healthy')
|
||||
|
||||
def test_failed_alert_is_retried_on_next_run(self):
|
||||
state = {}
|
||||
errors = ['naver_api: timeout', '38_fallback: timeout']
|
||||
with patch.object(sync, 'send_telegram', side_effect=[False, True]) as send:
|
||||
sync.update_parser_health(state, 'failed', errors, 0)
|
||||
sync.update_parser_health(state, 'failed', errors, 0)
|
||||
|
||||
self.assertEqual(send.call_count, 2)
|
||||
self.assertEqual(state['parser_alerted_status'], 'failed')
|
||||
|
||||
def test_primary_parser_gets_same_day_listing_augmentation(self):
|
||||
future = sync.EventSpec(
|
||||
'subscription', '넥스아이', date(2026, 11, 3), date(2026, 11, 4),
|
||||
'한국투자증권', 'https://stock.naver.com/ipo/A493410',
|
||||
)
|
||||
listing_html = '''
|
||||
<table summary="신규상장종목"><tbody>
|
||||
<tr><th>기업명</th><th>신규상장일</th></tr>
|
||||
<tr><td>빅웨이브로보틱스</td><td>2026/09/29</td></tr>
|
||||
</tbody></table>'''
|
||||
with patch.object(sync, 'parse_naver_api_events_from', return_value=[future]), patch.object(
|
||||
sync, 'fetch', return_value=listing_html
|
||||
):
|
||||
events, parser_used, errors = sync.collect_ipo_events(date(2026, 9, 29))
|
||||
|
||||
self.assertEqual(parser_used, 'naver_api')
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual({e.state_key for e in events}, {
|
||||
'subscription|넥스아이', 'listing|빅웨이브로보틱스',
|
||||
})
|
||||
|
||||
def test_primary_parser_backfills_recent_non_spac_listing(self):
|
||||
future = sync.EventSpec(
|
||||
'subscription', '넥스아이', date(2026, 11, 3), date(2026, 11, 4),
|
||||
'한국투자증권', 'https://stock.naver.com/ipo/A493410',
|
||||
)
|
||||
listing_html = '''
|
||||
<table summary="신규상장종목"><tbody>
|
||||
<tr><th>기업명</th><th>신규상장일</th></tr>
|
||||
<tr><td>와이즈플래닛컴퍼니</td><td>2026/09/23</td></tr>
|
||||
<tr><td>KB스팩34호</td><td>2026/09/22</td></tr>
|
||||
<tr><td>덕산넵코어스(구.넵코어스)</td><td>2026/09/30</td></tr>
|
||||
</tbody></table>'''
|
||||
with patch.object(sync, 'parse_naver_api_events_from', return_value=[future]), patch.object(
|
||||
sync, 'fetch', return_value=listing_html
|
||||
):
|
||||
events, parser_used, errors = sync.collect_ipo_events(date(2026, 9, 29))
|
||||
|
||||
self.assertEqual(parser_used, 'naver_api')
|
||||
self.assertEqual(errors, [])
|
||||
self.assertIn('listing|와이즈플래닛컴퍼니', {e.state_key for e in events})
|
||||
self.assertNotIn('listing|KB스팩34호', {e.state_key for e in events})
|
||||
self.assertNotIn(
|
||||
'listing|덕산넵코어스(구.넵코어스)', {e.state_key for e in events}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,76 @@
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
import stock_portfolio_report as report
|
||||
|
||||
|
||||
class NxtInactiveCorrectionTests(unittest.TestCase):
|
||||
def test_corrects_only_nxt_inactive_rows_with_krx_quote(self):
|
||||
rows = [
|
||||
{
|
||||
'code': '381620', 'qty': 10, 'price': 6290, 'pred_close': 6290,
|
||||
'day_change': 0, 'day_change_pct': 0.0, 'buy_amount': 58000,
|
||||
'eval_value': 62900, 'profit': 4900, 'profit_rate': 8.45,
|
||||
},
|
||||
{
|
||||
'code': '005930', 'qty': 2, 'price': 281000, 'pred_close': 275000,
|
||||
'day_change': 6000, 'day_change_pct': 2.18, 'buy_amount': 500000,
|
||||
'eval_value': 562000, 'profit': 62000, 'profit_rate': 12.4,
|
||||
},
|
||||
]
|
||||
cache = {
|
||||
'stocks': {
|
||||
'제닉스로보틱스': {'code': '381620', 'nxt_enable': False},
|
||||
'삼성전자': {'code': '005930', 'nxt_enable': True},
|
||||
}
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cache_path = Path(td) / 'stock_codes.json'
|
||||
cache_path.write_text(json.dumps(cache))
|
||||
with patch.object(report, 'STOCK_CODES_FILE', cache_path), patch.object(
|
||||
report,
|
||||
'get_watchlist_quotes',
|
||||
return_value={
|
||||
'381620': {'price': 6190, 'change': 40, 'change_pct': 0.65},
|
||||
},
|
||||
) as quotes:
|
||||
corrected = report.correct_nxt_inactive_rows(rows)
|
||||
|
||||
self.assertEqual(corrected, 1)
|
||||
quotes.assert_called_once_with(['381620'], exchange='KRX')
|
||||
self.assertEqual(rows[0]['price'], 6190)
|
||||
self.assertEqual(rows[0]['pred_close'], 6150)
|
||||
self.assertEqual(rows[0]['eval_value'], 61900)
|
||||
self.assertEqual(rows[0]['profit'], 3900)
|
||||
self.assertAlmostEqual(rows[0]['profit_rate'], 3900 / 58000 * 100)
|
||||
self.assertEqual(rows[1]['price'], 281000)
|
||||
|
||||
def test_keeps_original_row_when_krx_quote_is_missing(self):
|
||||
row = {
|
||||
'code': '381620', 'qty': 10, 'price': 6290, 'pred_close': 6290,
|
||||
'day_change': 0, 'day_change_pct': 0.0, 'buy_amount': 58000,
|
||||
'eval_value': 62900, 'profit': 4900, 'profit_rate': 8.45,
|
||||
}
|
||||
cache = {'stocks': {'제닉스로보틱스': {'code': '381620', 'nxt_enable': False}}}
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
cache_path = Path(td) / 'stock_codes.json'
|
||||
cache_path.write_text(json.dumps(cache))
|
||||
with patch.object(report, 'STOCK_CODES_FILE', cache_path), patch.object(
|
||||
report, 'get_watchlist_quotes', return_value={}
|
||||
):
|
||||
corrected = report.correct_nxt_inactive_rows([row])
|
||||
|
||||
self.assertEqual(corrected, 0)
|
||||
self.assertEqual(row['price'], 6290)
|
||||
self.assertEqual(row['eval_value'], 62900)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -67,6 +67,95 @@ def _save_all(rows: list[dict]) -> None:
|
||||
tmp.replace(JOURNAL)
|
||||
|
||||
|
||||
def _advance(qty: int, avg: float, buy_qty: int, sell_qty: int, buy_avg: float) -> tuple[int, float]:
|
||||
"""running 보유수량·평단 1스텝 진행.
|
||||
|
||||
매도는 **전 보유분 우선 소진**(평단 유지) → 잔여 당일 매수분을 가중평균 블렌딩.
|
||||
이 순서가 '전일 보유 평단' 정의의 핵심 — 당일 매수가 매도 원가에 섞이지 않고,
|
||||
매도로 원물이 다 빠지면 남는 건 당일 매수분 평단이 된다.
|
||||
"""
|
||||
consumed = min(sell_qty, qty)
|
||||
qty -= consumed
|
||||
rem_buy = buy_qty - (sell_qty - consumed) # 당일매수분 중 매도로 안 빠진 잔량
|
||||
if rem_buy > 0:
|
||||
avg = (qty * avg + rem_buy * buy_avg) / (qty + rem_buy)
|
||||
qty += rem_buy
|
||||
qty = max(qty, 0)
|
||||
if qty == 0:
|
||||
avg = 0.0 # 전량 청산 → 평단 리셋 (이후 순수 데이트레이딩을 보유분으로 오인 방지)
|
||||
return qty, avg
|
||||
|
||||
|
||||
def _apply_rebase(row: dict, sell_avg: float, sell_qty: int, pre_avg: float) -> None:
|
||||
"""row의 pl_amt/prft_rt를 '전일 보유 평단(pre_avg)' 기준으로 덮어쓴다 (in-place).
|
||||
|
||||
그로스 = (매도평단 - 전일평단) × 매도수량, pl_amt = 그로스 - 수수료·세금 (ka10170과 동일 net).
|
||||
"""
|
||||
gross = (sell_avg - pre_avg) * sell_qty
|
||||
fee = int(row.get('cmsn_tax') or 0)
|
||||
row['pl_amt'] = round(gross) - fee
|
||||
cost = pre_avg * sell_qty
|
||||
row['prft_rt'] = round(row['pl_amt'] / cost * 100, 2) if cost else 0.0
|
||||
row['_pl_rebased'] = True
|
||||
row['prev_avg'] = round(pre_avg)
|
||||
|
||||
|
||||
def rebase_pl(rows: list[dict]) -> list[dict]:
|
||||
"""매도 실현손익을 '전일 보유 평단' 기준으로 재계산해 **새 리스트** 반환 (파일 미변경, 표시 전용).
|
||||
|
||||
ka10170은 당일 매수+매도가 같이 있으면 당일 매수평단↔매도평단 round-trip으로 pl_amt를 매겨
|
||||
어제까지 보유한 원물의 손익을 가린다 (2026-06-29 0193W0: 6/26 보유 62@24,550 +
|
||||
당일 62@21,875 매수 후 62@21,885 매도 → ka10170 +220원, 실제 원물 손익 약 -165,630원).
|
||||
|
||||
(account, code)별로 날짜순 재생(_advance)하며, **당일 매수가 동반된 매도 행**만
|
||||
당일 매수 전 보유 평단을 원가로 잡아 덮어쓴다. 순수 매도(당일 매수 없음)는 키움 값이
|
||||
정확하므로 건드리지 않는다.
|
||||
|
||||
⚠️ 입력 rows는 각 (account, code)의 **전체 이력**을 포함해야 재생이 정확하다.
|
||||
"""
|
||||
out = [dict(r) for r in rows]
|
||||
by_key: dict[tuple, list[dict]] = {}
|
||||
for r in out:
|
||||
by_key.setdefault((r.get('account'), r.get('code')), []).append(r)
|
||||
for grp in by_key.values():
|
||||
grp.sort(key=lambda r: (r.get('date', ''), r.get('collected_at', '')))
|
||||
qty, avg = 0, 0.0
|
||||
for r in grp:
|
||||
bq = int(r.get('buy_qty') or 0)
|
||||
sq = int(r.get('sell_qty') or 0)
|
||||
ba = float(r.get('buy_avg') or 0)
|
||||
sa = float(r.get('sell_avg') or 0)
|
||||
pre_avg = avg # 당일 매수 반영 전 = 전일 보유 평단
|
||||
if sq > 0 and bq > 0 and pre_avg > 0:
|
||||
_apply_rebase(r, sa, sq, pre_avg)
|
||||
qty, avg = _advance(qty, avg, bq, sq, ba)
|
||||
return out
|
||||
|
||||
|
||||
def prev_avg_map(before_iso: str) -> dict:
|
||||
"""파일 이력 중 before_iso **미만**(전일까지)을 (account, code)별 재생해 시작 평단 맵 반환.
|
||||
|
||||
라이브 journal(당일분)의 round-trip 오염 행을 재계산할 때 원가(전일 보유 평단) 소스.
|
||||
{(account, code): avg}. 보유 0이면 0.
|
||||
"""
|
||||
rows = [r for r in _load_all() if r.get('date', '') < before_iso]
|
||||
by_key: dict[tuple, list[dict]] = {}
|
||||
for r in rows:
|
||||
by_key.setdefault((r.get('account'), r.get('code')), []).append(r)
|
||||
out: dict[tuple, float] = {}
|
||||
for key, grp in by_key.items():
|
||||
grp.sort(key=lambda r: (r.get('date', ''), r.get('collected_at', '')))
|
||||
qty, avg = 0, 0.0
|
||||
for r in grp:
|
||||
qty, avg = _advance(
|
||||
qty, avg,
|
||||
int(r.get('buy_qty') or 0), int(r.get('sell_qty') or 0),
|
||||
float(r.get('buy_avg') or 0),
|
||||
)
|
||||
out[key] = avg
|
||||
return out
|
||||
|
||||
|
||||
def record_trades(all_trades: dict, *, base_dt: str | None = None, quiet: bool = False, tag: str = 'collect') -> dict:
|
||||
"""이미 받은 ka10170 응답({label: [trades]})을 jsonl에 적재 (idempotent).
|
||||
`(iso_date, account in all_trades)` 단위 재적재 — 다른 계좌는 영향 X.
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
#!/usr/bin/env python3
|
||||
"""트레일링 스톱 감시 — 고점이 갱신되면 키움 스톱주문의 조건단가를 상향 정정한다.
|
||||
|
||||
**예약 1건 = 계단(레그) N개.** 고점 대비 하락률이 깊어질수록 더 많이 파는 계단식 청산이라
|
||||
계단마다 스톱주문이 따로 걸려 있다. 고점이 오르면 살아있는 레그를 전부 상향 정정한다.
|
||||
|
||||
LLM을 깨우지 않는다. 하는 일은 세 가지:
|
||||
1. 레그가 아직 미체결로 살아있는지 확인 (ka10075) — 사라졌으면 정리+알림
|
||||
2. 현재가가 저장된 고점을 넘었으면 kt10002 정정으로 계단별 조건단가·지정가를 함께 올림
|
||||
3. **예약 트레일링(TRAILING_ARM) 발동** — 현재가가 발동가에 닿으면 그 시점 현재가를
|
||||
고점으로 계단별 스톱주문을 발주하고, 대기 예약을 트레일링 예약으로 승격 (2026-08-25)
|
||||
|
||||
⚠️ 3번이 **신규 발주 경로**다 (2026-08-25 관리자님 명시 승인). 그전까지 이 감시가 허용된
|
||||
근거는 "modify_order 하나만 부른다" 였는데 이제는 다르다. 바뀐 근거는 **계좌·종목·수량·
|
||||
계단·발동가가 PIN 승인 시점에 전부 확정되고, 감시는 발동가 도달 판정만 한다** 는 것이다.
|
||||
수량 증가·방향 변경 경로는 여전히 없다 (매도가능 부족 시 **축소** 재배분만).
|
||||
|
||||
한 계단이 체결돼도 남은 계단을 재배치하지 않는다 — 재배치엔 취소+신규 발주가 필요해
|
||||
감시 루프에 (승인 범위를 넘는) 자동 발주 경로가 생긴다(2026-07-31 관리자님 결정).
|
||||
|
||||
⚠️ arm 단계는 손절선 상향이 **끝난 뒤** 별도 try/except 로 돈다. 순서가 설계다 — 상향은
|
||||
이미 나간 주문을 지키는 동작이라 arm 쪽 어떤 예외도 그걸 선점해선 안 된다.
|
||||
|
||||
⚠️ 발동은 2단계 마킹으로 보호한다. 발주 전에 firing_at 을 디스크에 남기고 레그마다
|
||||
주문번호를 적는다. **firing_at 이 남은 예약은 자동 재발동하지 않는다** — 주문이 나갔는지
|
||||
모르는 상태에서 다시 내는 게 곧 이중 매도다. 알림만 보내고 사람이 확인해 취소한다.
|
||||
|
||||
⚠️ 레그가 사라진 사유는 **주문번호 단위**인 kt00007 로 판정한다. 종목 단위 집계인
|
||||
당일매매일지(ka10170)를 쓰면 한 계단이 체결된 날 나머지 계단이 장 마감으로 소멸했을 때
|
||||
그 소멸분까지 '체결' 로 오판한다 — 같은 종목 매도 기록이 이미 남아 있기 때문이다.
|
||||
|
||||
⚠️ 정정하면 주문번호가 새로 발급된다 (kt10002 응답 ord_no). trailing.commit_step_modify 가
|
||||
상태파일의 레그별 ord_no 를 갱신하지 않으면 그 레그의 다음 정정부터 전부 실패한다.
|
||||
|
||||
⚠️ 고점은 '현재가' 로만 갱신한다. ka10095 가 당일 고가(high)도 주지만, 등록 시점 이전에
|
||||
찍힌 고가까지 반영되면 등록 직후 손절선이 현재가 위로 올라가 즉시 발동할 수 있다.
|
||||
(예: 오전 15,000 → 14,000 일 때 3% 등록 → 당일고가 기준이면 조건 14,550 > 현재가)
|
||||
감시 간격이 2분이라 현재가 샘플링으로 충분하다.
|
||||
|
||||
현재 launchd cadence 는 1분 1회 (`check`), 08:00~20:00 전 거래 세션.
|
||||
상시 daemon 을 띄우지 않는 이유는 매매 API 를 호출할 수 있는 프로세스를
|
||||
24시간 살려두지 않기 위함.
|
||||
|
||||
⚠️ NXT 시간대(프리 08:00~09:00 / 애프터 15:30~20:00) 감시는 2026-07-30 관리자님 요청으로 추가.
|
||||
**정규장에 등록된 주문(SOR `_AL` 또는 KRX `''`)의 정정이 NXT 단독 시간대에 받아들여지는지
|
||||
실증 안 됨.** 거부되면 고점 갱신마다 실패가 반복되므로 실패 알림에 30분 쿨다운을 걸었다
|
||||
(로그에는 매번 남으니 진단은 logs/stock-trailing-monitor.log 로).
|
||||
|
||||
⚠️ 1분보다 짧게 가려면 --repeat/--gap 을 쓴다. launchd 는 StartCalendarInterval 이
|
||||
분 단위가 최소라 30초 발화를 만들 수 없고, StartInterval=30 은 GUI 세션 idle 시
|
||||
발화가 보류돼(timer coalescing) 장중에 안 도는 문제가 있어 못 쓴다
|
||||
(sim-scan 이 같은 이유로 캘린더로 전환됨). 그래서 30초는 "1분 발화 + 한 프로세스가
|
||||
30초 간격 2회 검사" 로만 가능하다 — plist ProgramArguments 에 인자를 붙이면 된다.
|
||||
(2026-07-30 30초로 운영했다가 관리자님 지시로 1분 복귀. 옵션은 보존)
|
||||
|
||||
Usage:
|
||||
python3 trailing_monitor.py check # 1회 감시 (현재 launchd 설정)
|
||||
python3 trailing_monitor.py check --repeat 2 --gap 30 # 30초 간격 2회
|
||||
python3 trailing_monitor.py check --force # 장외에도 실행 (테스트용)
|
||||
python3 trailing_monitor.py check --dry-run # 정정 API 호출 없이 판단·body 만 출력
|
||||
python3 trailing_monitor.py list # 현재 예약 목록
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
KST = timezone(timedelta(hours=9))
|
||||
WORKSPACE = Path('/Users/snowoyh/.openclaw/agents/stock/workspace')
|
||||
sys.path.insert(0, str(WORKSPACE / 'scripts'))
|
||||
|
||||
import kiwoom_client as kc # noqa: E402
|
||||
from orders import card, datasource, guards, kiwoom_order, ledger, sidecar, trailing # noqa: E402
|
||||
from orders import handler # noqa: E402
|
||||
from orders.handler import send_telegram # noqa: E402
|
||||
|
||||
# 정정 실패 알림 쿨다운 — NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 반복된다.
|
||||
FAIL_NOTICE_COOLDOWN_SEC = 1800
|
||||
|
||||
# 사이클마다 mtime 만 갱신하는 생존 신호. 이 감시가 죽으면 예약 트레일링(ARM)이
|
||||
# 아예 발동하지 않는데, 예약 0건이면 로그를 한 줄도 남기지 않아 바깥에서 죽었는지
|
||||
# 구분할 방법이 없었다(2026-08-28 이후 13일간 로그 무갱신이 정상인지 장애인지 불명).
|
||||
# 로그로 찍으면 분당 1줄 × 720회/일이 쌓여 실제 이벤트를 덮으므로 파일 mtime 만 쓴다.
|
||||
# scripts/health_check.py 가 이 파일을 읽는다.
|
||||
HEARTBEAT_FILE = WORKSPACE / 'state' / 'trailing_monitor_heartbeat'
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
print(f'[{datetime.now(KST).strftime("%Y-%m-%d %H:%M:%S")}] {msg}', flush=True)
|
||||
|
||||
|
||||
def _heartbeat() -> None:
|
||||
"""감시가 이번 사이클을 돌았다는 신호. 실패해도 감시를 막지 않는다."""
|
||||
try:
|
||||
HEARTBEAT_FILE.write_text(
|
||||
datetime.now(KST).isoformat(timespec='seconds'), encoding='utf-8')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def session_now() -> tuple[bool, str]:
|
||||
"""(감시할 세션인가, 세션명). 주말·휴장일·거래시간 외는 False.
|
||||
|
||||
세션 판정은 orders/limits.json 을 단일 진실 소스로 쓰는 guards.session_at 재사용 —
|
||||
NXT 프리(08:00~09:00) / 정규(09:00~15:20) / 종가단일가(15:20~15:30) /
|
||||
NXT 애프터(15:30~20:00). 시간이 바뀌면 limits.json 만 고치면 된다.
|
||||
"""
|
||||
now = datetime.now(KST)
|
||||
if now.weekday() >= 5:
|
||||
return False, 'WEEKEND'
|
||||
if guards.is_today_holiday(now):
|
||||
return False, 'HOLIDAY'
|
||||
sess = guards.session_at(now)
|
||||
return sess != 'CLOSED', sess
|
||||
|
||||
|
||||
def _pct_s(v) -> str:
|
||||
"""10.0 → '10' — 계단 하락률 표기."""
|
||||
return f'{float(v or 0):g}'
|
||||
|
||||
|
||||
def _leg_fill_check(account: str, ord_no: str, cache: dict) -> tuple:
|
||||
"""레그가 사라진 사유 판정 — kt00007 에서 **그 주문번호의** 체결수량을 본다.
|
||||
|
||||
⚠️ 미체결 조회에서 사라진 것만으로는 체결·취소·소멸을 구분할 수 없다(셋 다 목록에서 빠짐).
|
||||
종목 단위인 당일매매일지(ka10170)로 판정하면 계단 하나가 체결된 날 나머지 계단이
|
||||
장 마감으로 소멸했을 때 그 소멸분까지 '체결' 로 오판한다 — 같은 종목의 매도 기록이
|
||||
이미 남아 있기 때문이다. 주문번호 단위인 kt00007 은 레그마다 정확히 갈린다.
|
||||
|
||||
반환: (체결행 dict 또는 None, 조회 성공 여부).
|
||||
조회 실패와 '체결 안 됨' 을 반드시 구분해야 한다 — 실패를 '체결 안 됨' 으로 단정하면
|
||||
실제로 팔렸는데 안 팔렸다고 알리게 된다.
|
||||
"""
|
||||
if account not in cache:
|
||||
try:
|
||||
cache[account] = kc.get_order_executions(account)
|
||||
except Exception as e:
|
||||
_log(f'주문체결내역 조회 실패 ({account}): {e!r}')
|
||||
cache[account] = None
|
||||
rows = cache[account]
|
||||
if rows is None:
|
||||
return None, False
|
||||
for row in rows:
|
||||
if (row.get('ord_no') or '').strip() == ord_no and (row.get('cntr_qty') or 0) > 0:
|
||||
return row, True
|
||||
return None, True
|
||||
|
||||
|
||||
def _notify_gone_batch(events: list) -> None:
|
||||
"""한 사이클에 사라진 레그들을 예약 단위로 묶어 한 번만 알린다.
|
||||
|
||||
장 마감이면 계단이 통째로 소멸하는데 레그마다 보내면 3~5통이 몰아친다.
|
||||
체결 / 미체결 소멸(장 마감 취소 등)은 관리자님이 취해야 할 행동이 완전히 다르므로
|
||||
계단별로 구분해 한 메시지 안에 나열한다.
|
||||
|
||||
2026-07-30 실측: 스톱주문은 장 마감 후 체결 없이 소멸한다(15:30~18:21 사이).
|
||||
자동 재등록은 하지 않는다(관리자님 지시) — 다시 걸려면 자산웹에서 수동 등록.
|
||||
"""
|
||||
by_res: dict = {}
|
||||
for ev in events:
|
||||
by_res.setdefault(ev['res']['id'], []).append(ev)
|
||||
|
||||
for evs in by_res.values():
|
||||
res = evs[0]['res']
|
||||
remaining = min(e['remaining'] for e in evs)
|
||||
any_filled = any(e['filled'] for e in evs)
|
||||
unknown = any(not e['ok'] for e in evs)
|
||||
# 재등록 버튼용 스냅샷 — 예약이 통째로 죽었고, 체결도 판정불가도 없을 때만.
|
||||
# 체결분을 다시 걸면 이미 판 걸 또 파는 셈이고, 판정불가는 체결일 수도 있어 같은 위험이다.
|
||||
# 판정 로직을 새로 쓰지 않고 여기서 같이 쓰는 이유 — 두 벌이 되면 한쪽만 고쳐진다.
|
||||
if remaining == 0 and not any_filled and not unknown:
|
||||
entry_steps = res.get('entry_steps')
|
||||
# ⚠️ 이번 배치 이벤트 수 < 원래 계단 수 = **앞선 사이클에 이미 빠진 레그가 있다**
|
||||
# (거의 항상 장중 체결). 이 배치엔 체결이 없어 위 조건을 통과하므로, 여기서
|
||||
# 막지 않으면 "일부는 이미 판" 설정을 재등록 후보로 올리게 된다.
|
||||
if entry_steps and len(evs) == len(entry_steps):
|
||||
try:
|
||||
trailing.push_recent(trailing.make_recent_entry(
|
||||
'trailing', 'not_in_open_orders', res, entry_steps))
|
||||
except Exception as e:
|
||||
_log(f'재등록 기록 실패 {res["id"]}: {e!r}')
|
||||
if any_filled:
|
||||
icon = '✅'
|
||||
word = '체결'
|
||||
elif unknown:
|
||||
icon = '🎯'
|
||||
word = '종료'
|
||||
else:
|
||||
icon = '⚠️'
|
||||
word = '소멸'
|
||||
lines = [f'{icon} 트레일링 {word} — {res["symbol_name"]} ({res["symbol"]})',
|
||||
f'계좌: {res["account"]} · 고점 {res["peak"]:,}원']
|
||||
if res.get('min_sell_price'):
|
||||
lines.append(f'최저 매도가 {res["min_sell_price"]:,}원')
|
||||
for ev in sorted(evs, key=lambda e: e['step']['n']):
|
||||
s = ev['step']
|
||||
head = (f'{s["n"]}단계 −{_pct_s(s["pct"])}% {s["cond_uv"]:,}원 · {s["qty"]:,}주')
|
||||
if ev['filled']:
|
||||
f = ev['filled']
|
||||
lines.append(f' ✅ {head} → 체결 {f.get("cntr_qty", 0):,}주 '
|
||||
f'@ {f.get("cntr_uv", 0):,}원')
|
||||
elif ev['ok']:
|
||||
lines.append(f' ⚠️ {head} → 미체결 소멸 (보유수량 그대로)')
|
||||
else:
|
||||
lines.append(f' 🎯 {head} → 체결 여부 판정 불가 (체결내역 조회 실패)')
|
||||
if remaining > 0:
|
||||
lines.append(f'남은 계단 {remaining}개 — 감시 계속합니다.')
|
||||
elif any_filled or unknown:
|
||||
# 체결·판정불가는 재등록 목록에 올리지 않는다(_archive 조건과 동일) —
|
||||
# 없는 목록으로 안내하면 안 되므로 여기서도 재등록 버튼을 언급하지 않는다.
|
||||
lines.append('남은 계단 없음 — 계속 쓰시려면 자산웹에서 다시 등록해주세요 '
|
||||
'(자동 재등록 안 함).')
|
||||
else:
|
||||
lines.append('남은 계단 없음 — 자산웹 [📋 진행중] → 최근 종료 에서 ↺ 재등록 '
|
||||
'(같은 설정이 채워집니다 · 자동 재등록은 안 합니다).')
|
||||
send_telegram('\n'.join(lines), parse_mode=None)
|
||||
|
||||
|
||||
def _notify_modify_failed(res: dict, step: dict, reason: str) -> None:
|
||||
send_telegram(
|
||||
f'⚠️ 트레일링 손절선 상향 실패 — {res["symbol_name"]} ({res["symbol"]})\n'
|
||||
f'계좌: {res["account"]} · {step["n"]}단계 −{_pct_s(step["pct"])}% '
|
||||
f'· 주문번호 {step["ord_no"]}\n'
|
||||
f'현재 손절선 {step["cond_uv"]:,}원은 그대로 유지됩니다.\n'
|
||||
f'사유: {reason}',
|
||||
parse_mode=None)
|
||||
|
||||
|
||||
def _sellable_qty(account: str, symbol: str, open_rows) -> tuple:
|
||||
"""(매도가능 수량, 조회성공). 조회 실패를 0 과 반드시 구분한다 — 눈감고 발주하면 안 되고,
|
||||
반대로 실패를 0 으로 읽으면 살아있는 예약을 지운다.
|
||||
|
||||
trde_able_qty 가 미체결 매도를 이미 뺐는지 확실하지 않아 min 으로 이중차감을 피한다.
|
||||
"""
|
||||
try:
|
||||
positions = kc.get_positions(account)
|
||||
except Exception as e:
|
||||
_log(f'보유 조회 실패 [{account}]: {e!r}')
|
||||
return 0, False
|
||||
pos = next((p for p in positions if p.get('code') == symbol), None)
|
||||
if pos is None:
|
||||
return 0, True
|
||||
hold = pos.get('qty') or 0
|
||||
able = pos.get('trde_able_qty') or 0
|
||||
pending = sum((o.get('unfilled_qty') or 0) for o in (open_rows or [])
|
||||
if o.get('code') == symbol and o.get('side') == 'SELL')
|
||||
return max(0, min(able, hold - pending)), True
|
||||
|
||||
|
||||
def _notify_arm_issue(arm: dict, text: str) -> None:
|
||||
"""대기 예약 관련 경고 — 예약 단위 쿨다운(매 사이클 반복되는 사유가 많다)."""
|
||||
if not trailing.should_notify_arm_failure(arm['id'], time.time(), FAIL_NOTICE_COOLDOWN_SEC):
|
||||
_log(f' 알림 생략 (쿨다운 {FAIL_NOTICE_COOLDOWN_SEC}초 내 이미 발송)')
|
||||
return
|
||||
try:
|
||||
send_telegram(
|
||||
f'⚠️ 예약 트레일링 — {arm["symbol_name"]} ({arm["symbol"]})\n'
|
||||
f'계좌: {arm["account"]} · 발동가 {arm["trigger_price"]:,}원 · {arm["qty"]:,}주\n'
|
||||
f'{text}',
|
||||
parse_mode=None)
|
||||
except Exception as e:
|
||||
_log(f'알림 실패: {e!r}')
|
||||
|
||||
|
||||
def _fire_arm(arm: dict, plan: dict, sess: str, dry_run: bool) -> None:
|
||||
"""발동 — 계단 발주 후 대기 예약을 트레일링 예약으로 승격. 여기부터 되돌릴 수 없다."""
|
||||
symbol = arm['symbol']
|
||||
# 라우팅은 등록 시점이 아니라 지금 정한다 — 같은 날 안에서도 정규장에 등록해 NXT
|
||||
# 애프터에 발동할 수 있고, 옛 suffix 로 발주하면 거부된다.
|
||||
try:
|
||||
suffix = guards.determine_routing(datetime.now(KST), datasource.nxt_eligible(symbol),
|
||||
None, 'STOP_LIMIT')
|
||||
except ValueError as e:
|
||||
_log(f'라우팅 결정 실패 {arm["id"]} (세션 {sess}): {e!r}')
|
||||
return
|
||||
|
||||
if dry_run:
|
||||
_log(f'[dry-run] 발동 {arm["id"]} {arm["symbol_name"]} '
|
||||
f'발동가 {arm["trigger_price"]:,} ≤ 현재가 {plan["peak"]:,} · '
|
||||
f'{plan["qty_used"]:,}주 · routing {suffix or "KRX"}')
|
||||
for s in plan['legs']:
|
||||
_log(f' {s["n"]}단계 −{s["pct"]}% 조건 {s["cond_uv"]:,} / 지정 {s["ord_uv"]:,} '
|
||||
f'· {s["qty"]:,}주')
|
||||
return
|
||||
|
||||
# write-ahead: 발주를 시작했다는 사실을 먼저 디스크에 남긴다. 이 마킹이 있으면 다음
|
||||
# 사이클은 재발동하지 않는다 — 주문이 나갔는지 모르는 상태에서 다시 내는 게 이중 매도다.
|
||||
if not trailing.mark_arm_firing(arm['id']):
|
||||
_log(f'발동 마킹 실패 {arm["id"]} — 이미 발주 시도된 예약 (재발동 안 함)')
|
||||
return
|
||||
|
||||
placed, failed, _details = handler.place_trailing_legs(
|
||||
account=arm['account'], symbol=symbol, steps=plan['legs'],
|
||||
routing_suffix=suffix, card_id=arm.get('card_id'), dry_run=False,
|
||||
on_placed=lambda leg: trailing.record_arm_leg(arm['id'], leg))
|
||||
|
||||
if not placed:
|
||||
reason = failed[0]['reason'] if failed else 'UNKNOWN'
|
||||
detail = failed[0].get('error', '') if failed else ''
|
||||
_log(f'발동 전량 실패 {arm["id"]} {arm["symbol_name"]}: {reason} {detail}')
|
||||
ledger.append('arm_fire_failed', {'arm_id': arm['id'], 'account': arm['account'],
|
||||
'symbol': symbol, 'reason': reason,
|
||||
'error': str(detail), 'stage': 'submit'})
|
||||
# firing 마킹은 그대로 둔다 — 정말 한 건도 안 나갔는지 우리가 단정할 수 없다.
|
||||
_notify_arm_issue(arm, f'계단 {len(plan["legs"])}단 전부 접수 실패 ({reason}) {detail}\n'
|
||||
f'예약은 발동 처리 중단 상태로 남습니다 — 미체결을 확인하고 '
|
||||
f'자산웹에서 취소해 주세요.')
|
||||
return
|
||||
|
||||
registerable = [s for s in placed if s.get('ord_no')]
|
||||
promoted = None
|
||||
if registerable:
|
||||
try:
|
||||
promoted = trailing.promote_arm(arm['id'], plan['peak'], registerable, suffix)
|
||||
except Exception as e:
|
||||
_log(f'⚠️ 승격 실패 {arm["id"]}: {e!r}')
|
||||
|
||||
info = {'card_id': arm.get('card_id'), 'symbol': symbol, 'symbol_name': arm['symbol_name'],
|
||||
'account': arm['account'], 'total_qty': plan['qty_used'], 'arm_qty': arm['qty'],
|
||||
'qty_reduced': plan['reduced'], 'trigger_price': arm['trigger_price'],
|
||||
'peak': plan['peak'], 'min_sell_price': arm.get('min_sell_price'),
|
||||
'placed': placed, 'failed': failed, 'dry_run': False,
|
||||
'res_id': (promoted['reservation']['id'] if promoted else None),
|
||||
'register_error': None if promoted else '감시 등록 실패 — 손절선이 따라 올라가지 않습니다'}
|
||||
ledger.append('arm_fired', {'arm_id': arm['id'], 'account': arm['account'], 'symbol': symbol,
|
||||
'peak': plan['peak'], 'qty': plan['qty_used'],
|
||||
'legs': len(placed), 'res_id': info['res_id']})
|
||||
_log(f'발동 {arm["id"]} → {info["res_id"]} {arm["symbol_name"]} '
|
||||
f'{len(placed)}단 · {plan["qty_used"]:,}주 · 고점 {plan["peak"]:,}')
|
||||
try:
|
||||
send_telegram(card.format_arm_fired(info), parse_mode=None)
|
||||
except Exception as e:
|
||||
_log(f'알림 실패: {e!r}')
|
||||
|
||||
|
||||
def _expire_arms(arms: list, dry_run: bool) -> list:
|
||||
"""만료된 대기 예약을 지우고 살아있는 것만 돌려준다 (2026-08-25).
|
||||
|
||||
스톱주문은 장 마감에 거래소가 지워주지만 대기 예약은 우리 파일에만 있으니 우리가 지운다.
|
||||
감시 루프가 08:00~19:59 에만 도므로 실제 정리 시점은 **다음 거래일 첫 사이클**이다.
|
||||
|
||||
알림은 한 통으로 묶는다 — 여러 종목을 걸어두고 하루가 지나면 예약마다 보내면 아침에
|
||||
텔레그램이 몰아친다.
|
||||
"""
|
||||
alive, dead = [], []
|
||||
for a in arms:
|
||||
(dead if trailing.is_arm_expired(a) else alive).append(a)
|
||||
if not dead:
|
||||
return alive
|
||||
for a in dead:
|
||||
_log(f'대기 예약 만료 {a["id"]} {a["symbol_name"]} '
|
||||
f'(등록 {a.get("created_at", "?")[:16]} · 발동가 {a["trigger_price"]:,})')
|
||||
if not dry_run:
|
||||
trailing.remove_arm(a['id'], 'expired_end_of_day')
|
||||
# 재등록 버튼용 스냅샷. 삭제 뒤에 남긴다 — 앞에 남기고 죽으면 중복이 생긴다.
|
||||
# ⚠️ firing_at 이 걸린 예약은 제외 — 주문이 나갔는지 모르는 상태라, 재등록을
|
||||
# 권하면 이중 매도로 유도하는 셈이다(사람이 미체결부터 확인해야 한다).
|
||||
if not a.get('firing_at'):
|
||||
try:
|
||||
trailing.push_recent(trailing.make_recent_entry(
|
||||
'arm', 'expired_end_of_day', a, a.get('steps') or []))
|
||||
except Exception as e:
|
||||
_log(f'재등록 기록 실패 {a["id"]}: {e!r}')
|
||||
if dry_run:
|
||||
return alive
|
||||
lines = [f'🗓 예약 트레일링 만료 취소 — {len(dead)}건',
|
||||
'장 마감이 지나 자동 취소했습니다 (스톱주문과 같은 당일 유효).']
|
||||
for a in dead:
|
||||
lines.append(f'· {a["symbol_name"]} ({a["symbol"]}) [{a["account"]}] '
|
||||
f'{a["qty"]:,}주 · 발동가 {a["trigger_price"]:,}원 — 미발동')
|
||||
lines.append('계속 노리시려면 자산웹 [📋 진행중] → 최근 종료 에서 ↺ 재등록 을 누르세요 '
|
||||
'(같은 설정이 채워집니다 · PIN 은 평소대로).')
|
||||
try:
|
||||
send_telegram('\n'.join(lines), parse_mode=None)
|
||||
except Exception as e:
|
||||
_log(f'알림 실패: {e!r}')
|
||||
return alive
|
||||
|
||||
|
||||
def _check_arms(arms: list, quotes: dict, open_by_acct: dict, sess: str, dry_run: bool) -> None:
|
||||
"""대기 예약 발동 판정. 평시(미도달)엔 추가 API 콜 0 — 시세는 호출측 배치를 재사용한다.
|
||||
|
||||
만료분은 호출측(check)이 이미 걷어낸 상태로 넘어온다 — 시세 조회가 실패해도 정리는
|
||||
돼야 하므로 순서상 앞에 둔다.
|
||||
"""
|
||||
# 사이드카는 마킹 전에 본다. 마킹 후 전 레그가 SidecarBlocked 로 죽으면 예약이 발동 처리
|
||||
# 중단 상태로 갇힌다.
|
||||
if sidecar.is_disabled():
|
||||
reached = [a for a in arms
|
||||
if ((quotes.get(a['symbol']) or {}).get('price') or 0) >= a['trigger_price']]
|
||||
for a in reached:
|
||||
_log(f'사이드카 ON — 발동 보류 {a["id"]} {a["symbol_name"]}')
|
||||
_notify_arm_issue(a, '사이드카가 켜져 있어 발동을 보류했습니다 (매매 차단). '
|
||||
'끄면 다음 사이클에 발동합니다.')
|
||||
return
|
||||
|
||||
for arm in arms:
|
||||
if arm.get('firing_at'):
|
||||
_log(f'발동 처리 중단 상태 {arm["id"]} {arm["symbol_name"]} '
|
||||
f'(firing_at={arm["firing_at"]}) — 재발동 안 함')
|
||||
legs = arm.get('fired_legs') or []
|
||||
nos = ', '.join(s.get('ord_no', '') for s in legs if s.get('ord_no')) or '없음'
|
||||
_notify_arm_issue(arm, f'발동 처리가 중단된 상태입니다 (시작 {arm["firing_at"]}).\n'
|
||||
f'접수된 주문: {nos}\n'
|
||||
f'키움 미체결을 확인하고 자산웹에서 예약을 취소해 주세요. '
|
||||
f'중복 매도를 막기 위해 자동 재시도하지 않습니다.')
|
||||
continue
|
||||
|
||||
cur = (quotes.get(arm['symbol']) or {}).get('price') or 0
|
||||
if cur < arm['trigger_price']:
|
||||
continue # 평시 경로 — 여기서 끝나면 API 콜 0
|
||||
|
||||
# 미체결 조회가 실패한 계좌는 판단을 보류한다 — 예약 경로와 같은 규칙이다.
|
||||
# 미체결 매도 수량을 모르는 채로 발주하면 과다 매도가 될 수 있다(0 으로 단정 금지).
|
||||
open_rows = open_by_acct.get(arm['account'])
|
||||
if open_rows is None:
|
||||
_log(f'미체결 조회 실패로 발동 보류 {arm["id"]} {arm["symbol_name"]}')
|
||||
continue
|
||||
|
||||
sellable, ok = _sellable_qty(arm['account'], arm['symbol'], open_rows)
|
||||
if not ok:
|
||||
_log(f'보유 조회 실패로 발동 보류 {arm["id"]} {arm["symbol_name"]}')
|
||||
continue # 눈감고 발주하지 않는다. 다음 사이클에 다시 본다
|
||||
|
||||
plan = trailing.plan_arm_fire(arm, cur, sellable)
|
||||
if plan['action'] == 'drop':
|
||||
why = {'no_sellable': '보유 수량이 없어', 'qty_too_small': '수량이 부족해 계단을 나눌 수 없어'}
|
||||
_log(f'발동 불가 {arm["id"]} {arm["symbol_name"]} — {plan["reason"]}')
|
||||
if not dry_run:
|
||||
trailing.remove_arm(arm['id'], plan['reason'])
|
||||
send_telegram(
|
||||
f'🗑 예약 트레일링 삭제 — {arm["symbol_name"]} ({arm["symbol"]})\n'
|
||||
f'계좌: {arm["account"]} · 발동가 {arm["trigger_price"]:,}원 도달했지만 '
|
||||
f'{why.get(plan["reason"], plan["reason"])} 발동하지 못했습니다.\n'
|
||||
f'예약 {arm["qty"]:,}주 · 매도가능 {sellable:,}주',
|
||||
parse_mode=None)
|
||||
continue
|
||||
if plan['action'] != 'fire':
|
||||
_log(f'발동 보류 {arm["id"]} {arm["symbol_name"]} — {plan["reason"]}')
|
||||
continue
|
||||
|
||||
_fire_arm(arm, plan, sess, dry_run)
|
||||
|
||||
|
||||
def _worth_running(force: bool) -> bool:
|
||||
"""볼 게 있고 거래 세션인가. 루프 제어와 단발 실행이 같은 기준을 쓰게 분리."""
|
||||
if not trailing.list_active() and not trailing.list_arms():
|
||||
return False
|
||||
return force or session_now()[0]
|
||||
|
||||
|
||||
def check(force: bool = False, dry_run: bool = False) -> int:
|
||||
_heartbeat()
|
||||
reservations = trailing.list_active()
|
||||
arms = trailing.list_arms()
|
||||
if not reservations and not arms:
|
||||
return 0
|
||||
ok_sess, sess = session_now()
|
||||
if not force and not ok_sess:
|
||||
_log(f'{sess} — skip (예약 {len(reservations)}건 · 대기 예약 {len(arms)}건)')
|
||||
return 0
|
||||
# NXT 단독 시간대엔 KRX 호가가 15:30 종가로 고정돼 현재가와 갭이 생긴다 → NXT 시세로 조회.
|
||||
quote_exchange = 'NX' if sess in ('NXT_PRE', 'NXT_AFTER') else 'AL'
|
||||
|
||||
# 만료된 대기 예약은 시세보다 먼저 걷어낸다 — 시세 조회가 실패해도 정리는 돼야 하고,
|
||||
# 어제 예약이 오늘 시세로 발동하는 일은 절대 없어야 한다.
|
||||
if arms:
|
||||
arms = _expire_arms(arms, dry_run)
|
||||
if not reservations and not arms:
|
||||
return 0
|
||||
|
||||
# 계좌별 미체결 스냅샷 (계좌당 1콜). 스톱주문도 조건 도달 전까지 미체결로 잡힌다.
|
||||
open_by_acct: dict[str, list] = {}
|
||||
for acct in sorted({r['account'] for r in reservations} | {a['account'] for a in arms}):
|
||||
try:
|
||||
open_by_acct[acct] = kc.get_open_orders(acct, side='sell')
|
||||
except Exception as e:
|
||||
# 한 계좌 조회 실패로 다른 계좌 감시를 멈추지 않는다. 생존 판정은 건너뛴다
|
||||
# (없다고 단정하면 살아있는 예약을 지워버린다).
|
||||
_log(f'미체결 조회 실패 [{acct}]: {e!r}')
|
||||
open_by_acct[acct] = None
|
||||
|
||||
# 레그가 사라질 때만 kt00007 을 계좌당 1콜 추가로 부른다 (평시엔 호출 안 함).
|
||||
exec_cache: dict = {}
|
||||
gone_events = []
|
||||
|
||||
alive = []
|
||||
for r in reservations:
|
||||
rows = open_by_acct.get(r['account'])
|
||||
if rows is None:
|
||||
continue # 조회 실패 계좌 — 이번 사이클 판단 보류
|
||||
live_steps = []
|
||||
for s in list(r.get('steps') or []):
|
||||
row = next((x for x in rows if x['ord_no'] == s['ord_no']), None)
|
||||
if row is not None:
|
||||
live_steps.append((s, row))
|
||||
continue
|
||||
filled, exec_ok = _leg_fill_check(r['account'], s['ord_no'], exec_cache)
|
||||
gone = trailing.remove_step(r['id'], s['n'], 'not_in_open_orders')
|
||||
if filled:
|
||||
why = f'체결 {filled.get("cntr_qty", 0):,}주 @ {filled.get("cntr_uv", 0):,}'
|
||||
elif exec_ok:
|
||||
why = '미체결 소멸 (체결수량 0 — 장 마감 취소 추정)'
|
||||
else:
|
||||
why = '사유 판정 불가 (주문체결내역 조회 실패)'
|
||||
_log(f'레그 종료 {r["id"]}/{s["n"]}단계 {r["symbol_name"]} — {why}')
|
||||
if gone:
|
||||
gone_events.append({'res': gone['reservation'], 'step': gone['step'],
|
||||
'filled': filled, 'ok': exec_ok,
|
||||
'remaining': gone['remaining']})
|
||||
if live_steps:
|
||||
alive.append((r, live_steps))
|
||||
|
||||
if gone_events and not dry_run:
|
||||
try:
|
||||
_notify_gone_batch(gone_events)
|
||||
except Exception as e:
|
||||
_log(f'알림 실패: {e!r}')
|
||||
|
||||
# 시세는 예약·대기 예약 심볼을 합쳐 한 번에 받는다 — ka10095 배치 1콜이라 대기 예약이
|
||||
# 늘어도 API 콜이 추가되지 않는다. 실패하면 두 단계 모두 이번 사이클을 건너뛴다.
|
||||
quote_codes = sorted({r['symbol'] for r, _ in alive} | {a['symbol'] for a in arms})
|
||||
if not quote_codes:
|
||||
return 0
|
||||
|
||||
try:
|
||||
quotes = kc.get_watchlist_quotes(quote_codes, exchange=quote_exchange)
|
||||
except Exception as e:
|
||||
_log(f'시세 조회 실패 ({quote_exchange}): {e!r}')
|
||||
return 1
|
||||
|
||||
raised = 0
|
||||
for r, live_steps in alive:
|
||||
cur = (quotes.get(r['symbol']) or {}).get('price') or 0
|
||||
# 살아있는 레그만 담은 스냅샷으로 판단 — 이미 빠진 계단은 따라 올릴 대상이 아니다.
|
||||
snap = {'peak': r['peak'], 'min_sell_price': r.get('min_sell_price'),
|
||||
'steps': [s for s, _ in live_steps]}
|
||||
nx = trailing.next_step_levels(snap, cur)
|
||||
if not nx:
|
||||
continue
|
||||
if not nx['steps']:
|
||||
# 고점은 올랐지만 호가단위·최저 매도가 때문에 올릴 조건단가가 없다 → 정정 API 콜 0.
|
||||
if not dry_run:
|
||||
trailing.commit_peak(r['id'], nx['peak'])
|
||||
continue
|
||||
|
||||
for up in nx['steps']:
|
||||
s, row = next(x for x in live_steps if x[0]['n'] == up['n'])
|
||||
# 미체결 잔량으로 정정 — 부분체결됐으면 잔량만 남는다 (저장된 qty 는 최초 주문수량).
|
||||
qty = row.get('unfilled_qty') or s['qty']
|
||||
try:
|
||||
res = kiwoom_order.modify_order(
|
||||
account_label=r['account'],
|
||||
orig_ord_no=s['ord_no'],
|
||||
symbol=r['symbol'],
|
||||
modify_qty=qty,
|
||||
modify_price=up['ord_uv'],
|
||||
routing_suffix=row.get('routing_suffix', ''),
|
||||
dry_run=dry_run,
|
||||
card_id=r.get('card_id'),
|
||||
modify_cond_price=up['cond_uv'],
|
||||
)
|
||||
except Exception as e:
|
||||
# sidecar 비활성(매매 차단) 포함. 손절선은 기존 값이 그대로 살아있다.
|
||||
_log(f'정정 예외 {r["id"]}/{s["n"]}단계 {r["symbol_name"]}: {e!r}')
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
_log(f'[dry-run] {r["id"]}/{s["n"]}단계 {r["symbol_name"]} '
|
||||
f'고점 {r["peak"]:,}→{nx["peak"]:,} · '
|
||||
f'손절 {s["cond_uv"]:,}→{up["cond_uv"]:,} / 지정 {up["ord_uv"]:,} · '
|
||||
f'body={res.get("body")}')
|
||||
continue
|
||||
|
||||
if res.get('ok'):
|
||||
new_ord_no = res.get('new_ord_no') or ''
|
||||
updated = trailing.commit_step_modify(r['id'], s['n'], new_ord_no,
|
||||
nx['peak'], up['cond_uv'], up['ord_uv'])
|
||||
raised += 1
|
||||
_log(f'손절선 상향 {r["id"]}/{s["n"]}단계 {r["symbol_name"]} '
|
||||
f'{s["cond_uv"]:,}→{up["cond_uv"]:,}원 (고점 {nx["peak"]:,}) '
|
||||
f'ord_no {s["ord_no"]}→{new_ord_no}')
|
||||
if not updated:
|
||||
# 상태 갱신 실패 = 다음 정정이 옛 ord_no 로 나가 실패한다. 반드시 알린다.
|
||||
_log(f'⚠️ 상태 갱신 실패 {r["id"]}/{s["n"]}단계 — ord_no 불일치 위험')
|
||||
try:
|
||||
_notify_modify_failed(r, s, f'정정은 성공했지만 상태 갱신 실패 '
|
||||
f'(새 주문번호 {new_ord_no})')
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
reason = res.get('reason', 'UNKNOWN')
|
||||
detail = (res.get('response') or {}).get('return_msg') or res.get('error') or ''
|
||||
_log(f'정정 실패 {r["id"]}/{s["n"]}단계 {r["symbol_name"]}: '
|
||||
f'{reason} {detail} (세션 {sess})')
|
||||
# NXT 시간대에 KRX/SOR 원주문 정정이 거부되면 매 사이클 반복된다 — 쿨다운으로
|
||||
# 스팸 차단. 쿨다운은 예약 단위다(같은 원인으로 전 계단이 함께 실패하므로
|
||||
# 계단마다 알리면 한 사이클에 3~5통이 된다). 로그에는 매번 남는다.
|
||||
if trailing.should_notify_failure(r['id'], time.time(), FAIL_NOTICE_COOLDOWN_SEC):
|
||||
try:
|
||||
_notify_modify_failed(r, s, f'{reason} {detail}'.strip() + f' · 세션 {sess}')
|
||||
except Exception as e:
|
||||
_log(f'알림 실패: {e!r}')
|
||||
else:
|
||||
_log(f' 알림 생략 (쿨다운 {FAIL_NOTICE_COOLDOWN_SEC}초 내 이미 발송)')
|
||||
|
||||
# 대기 예약 발동은 손절선 상향이 **끝난 뒤에** 한다. 순서가 설계다 — 상향은 이미 나간
|
||||
# 주문을 지키는 동작이라 arm 쪽 어떤 예외도 그걸 선점하면 안 된다. 그래서 통째로 감싼다.
|
||||
# arm 이 0건이면 이 아래는 실행되지 않아 기존 동작과 동일하다.
|
||||
if arms:
|
||||
try:
|
||||
_check_arms(arms, quotes, open_by_acct, sess, dry_run)
|
||||
except Exception as e:
|
||||
_log(f'대기 예약 단계 예외 (트레일링 감시는 정상 완료): {e!r}')
|
||||
|
||||
if raised:
|
||||
_log(f'완료 — {raised}개 계단 상향 / 예약 {len(alive)}건')
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_list() -> int:
|
||||
reservations = trailing.list_active()
|
||||
arms = trailing.list_arms()
|
||||
if not reservations and not arms:
|
||||
print('트레일링 예약 없음')
|
||||
return 0
|
||||
if arms:
|
||||
print(f'── 대기 예약 (발동가 도달 시 시작) {len(arms)}건 ──')
|
||||
for a in arms:
|
||||
steps = a.get('steps') or []
|
||||
floor_part = f' · 최저 {a["min_sell_price"]:,}' if a.get('min_sell_price') else ''
|
||||
print(f'{a["id"]} · {a["symbol_name"]}({a["symbol"]}) · {a["account"]} · '
|
||||
f'{a["qty"]:,}주 · {len(steps)}단')
|
||||
print(f' 발동가 {a["trigger_price"]:,}{floor_part} · 등록 {a["created_at"]}')
|
||||
for s in steps:
|
||||
cum = s.get('cum', 0)
|
||||
cum_part = '전량' if cum >= 100 else f'누적 {_pct_s(cum)}%'
|
||||
print(f' {s["n"]}단계 −{_pct_s(s["pct"])}% · {cum_part} '
|
||||
f'(주수는 발동 시점 매도가능 기준으로 배분)')
|
||||
if a.get('firing_at'):
|
||||
legs = a.get('fired_legs') or []
|
||||
nos = ', '.join(s.get('ord_no', '') for s in legs if s.get('ord_no')) or '없음'
|
||||
print(f' ⚠️ 발동 처리 중단 (시작 {a["firing_at"]}) · 접수된 주문: {nos}')
|
||||
if reservations and arms:
|
||||
print(f'── 트레일링 예약 {len(reservations)}건 ──')
|
||||
for r in reservations:
|
||||
steps = r.get('steps') or []
|
||||
floor_part = f' · 최저 {r["min_sell_price"]:,}' if r.get('min_sell_price') else ''
|
||||
entry_peak = r.get('entry_peak') or 0
|
||||
peak_part = f'고점 {r["peak"]:,}'
|
||||
if entry_peak and r['peak'] > entry_peak:
|
||||
peak_part += f' (등록 시 {entry_peak:,})'
|
||||
print(f'{r["id"]} · {r["symbol_name"]}({r["symbol"]}) · {r["account"]} · '
|
||||
f'{r["qty"]:,}주 · {len(steps)}단')
|
||||
print(f' {peak_part}{floor_part} · 등록 {r["created_at"]} · 갱신 {r["updated_at"]}')
|
||||
for s in steps:
|
||||
cum = s.get('cum', 0)
|
||||
cum_part = '전량' if cum >= 100 else f'누적 {_pct_s(cum)}%'
|
||||
print(f' {s["n"]}단계 −{_pct_s(s["pct"])}% · 손절 {s["cond_uv"]:,} / '
|
||||
f'지정 {s["ord_uv"]:,} · {s["qty"]:,}주 ({cum_part}) · '
|
||||
f'ord_no {s["ord_no"]} · 상향 {s.get("modify_count", 0)}회')
|
||||
return 0
|
||||
|
||||
|
||||
def _int_opt(argv: list[str], name: str, default: int) -> int:
|
||||
if name not in argv:
|
||||
return default
|
||||
i = argv.index(name)
|
||||
if i + 1 >= len(argv):
|
||||
return default
|
||||
try:
|
||||
return int(argv[i + 1])
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
cmd = argv[1] if len(argv) > 1 else 'check'
|
||||
if cmd == 'list':
|
||||
return cmd_list()
|
||||
if cmd == 'check':
|
||||
force = '--force' in argv
|
||||
dry_run = '--dry-run' in argv
|
||||
repeat = max(1, _int_opt(argv, '--repeat', 1))
|
||||
gap = max(1, _int_opt(argv, '--gap', 30))
|
||||
rc = 0
|
||||
for i in range(repeat):
|
||||
rc = check(force=force, dry_run=dry_run) or rc
|
||||
if i >= repeat - 1:
|
||||
break
|
||||
# 예약 0건이거나 장 마감이면 남은 sweep 을 버린다 — 빈 sleep 으로 프로세스가
|
||||
# 살아있을 이유가 없다. sleep '전에' 확인해야 한다.
|
||||
if not _worth_running(force):
|
||||
break
|
||||
time.sleep(gap)
|
||||
return rc
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main(sys.argv))
|
||||
@@ -119,6 +119,11 @@ def _cmd_variants(argv):
|
||||
elif sub == 'clear':
|
||||
variants.clear()
|
||||
print('변이 정의·계좌 전부 삭제')
|
||||
elif sub == 'philosophy':
|
||||
res = variants.add_philosophy_presets()
|
||||
print(f"철학별 전략 추가: 신규 {len(res['added'])}개 · 중복 {res['skipped']}개 · 총 {res['total']}개")
|
||||
for v in res['added']:
|
||||
print(' ', v['id'], v['name'])
|
||||
else:
|
||||
vs = variants.load_variants()
|
||||
print(f'변이 {len(vs)}개:')
|
||||
|
||||
@@ -182,7 +182,7 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
|
||||
pos['last_add_price'] = fill
|
||||
cash -= cost
|
||||
nstop, ntarget = signals.compute_stop_target(pos['entry_price'], od['atr'], od['recent_low'])
|
||||
pos['stop'] = max(pos['stop'], nstop)
|
||||
pos['stop'] = nstop if od.get('add_kind') == 'down' else max(pos['stop'], nstop)
|
||||
pos['target'] = ntarget
|
||||
elif kind == 'buy':
|
||||
if code in positions or len(positions) >= config.MAX_POSITIONS or exited.get(code) == day:
|
||||
@@ -223,7 +223,7 @@ def run(overrides: dict | None, codes: list[str], date_from: str = '', date_to:
|
||||
if act in ('sell', 'scale_out'):
|
||||
pending.append({'kind': act, 'code': code, 'frac': dec.get('sell_frac', 1.0)})
|
||||
elif act == 'add':
|
||||
pending.append({'kind': 'add', 'code': code,
|
||||
pending.append({'kind': 'add', 'code': code, 'add_kind': dec.get('add_kind'),
|
||||
'atr': ind['atr'], 'recent_low': ind['recent_low']})
|
||||
|
||||
# ---- 2. 신규 매수 판단 (당일 종가) → 내일 시가 주문 큐잉 ----
|
||||
|
||||
@@ -41,6 +41,9 @@ REL_STRENGTH_DAYS = 20 # 상대강도: 종목 N일 수익률 − 시
|
||||
TREND_SLOPE_GATE = 1 # 추세 게이트 ① 장기선 우상향 요구 (1=요구/0=완화). 보수성 완화 변이 실험용
|
||||
MID_ARRAY_GATE = 1 # 추세 게이트 ② 5일선>중기선(60) 요구 (1=요구/0=완화)
|
||||
IMMEDIATE_ENTRY = 0 # 즉시매수: 조건 통과 종목이 돌파·눌림목 둘 다 아니어도 강세장이면 현재가 즉시 매수 (1=켜기). 꾸준한 상승 종목 놓침 방지 실험용
|
||||
AVERAGING_ENTRY = 0 # 물타기(애버리징): 보유 중 직전매수가 대비 하락 시 손절 대신 추가매수로 평단↓ (1=켜기). 종목당 10% 한도 내, 급락·약세장 제외
|
||||
AVERAGING_DROP_PCT = 0.10 # 물타기 트리거 — 직전 매수가 대비 이 비율 하락 시 추가 (0.10=-10%마다)
|
||||
CRASH_DROP_PCT = 0.10 # 당일 단일 하락이 이 비율 이상이면 물타기 말고 손절 (그림 #3, 0.10=-10%↓)
|
||||
ATR_PERIOD = 14
|
||||
BREAKOUT_LOOKBACK = 20 # 돌파 판정용 최근 고점 구간
|
||||
SWING_LOW_LOOKBACK = 10 # 손절선 후보 스윙 저점 구간
|
||||
@@ -93,6 +96,8 @@ TUNABLE = [
|
||||
{'key': 'TREND_SLOPE_GATE', 'label': '장기선 우상향 요구', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '추세'},
|
||||
{'key': 'MID_ARRAY_GATE', 'label': '중기 정배열(5>60) 요구', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '추세'},
|
||||
{'key': 'IMMEDIATE_ENTRY', 'label': '즉시매수(눌림 안 기다림)', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '매수'},
|
||||
{'key': 'AVERAGING_ENTRY', 'label': '물타기(하락 시 추가매수)', 'type': 'int', 'min': 0, 'max': 1, 'step': 1, 'group': '매도'},
|
||||
{'key': 'AVERAGING_DROP_PCT', 'label': '물타기 하락 트리거', 'type': 'float', 'min': 0.03, 'max': 0.3, 'step': 0.01, 'group': '매도'},
|
||||
{'key': 'ATR_PERIOD', 'label': 'ATR 기간(일)', 'type': 'int', 'min': 5, 'max': 60, 'step': 1, 'group': '변동성·기간'},
|
||||
{'key': 'BREAKOUT_LOOKBACK', 'label': '돌파 고점 구간(일)', 'type': 'int', 'min': 5, 'max': 120, 'step': 1, 'group': '변동성·기간'},
|
||||
{'key': 'SWING_LOW_LOOKBACK', 'label': '스윙 저점 구간(일)', 'type': 'int', 'min': 3, 'max': 60, 'step': 1, 'group': '변동성·기간'},
|
||||
|
||||
@@ -225,10 +225,33 @@ def _ind_key(code):
|
||||
config.RSI_PERIOD, config.TREND_SLOPE_DAYS)
|
||||
|
||||
|
||||
def _gather(extra_codes=None):
|
||||
LAST_QUOTES_PATH = config.STATE_DIR / 'last_quotes.json'
|
||||
|
||||
|
||||
def _save_last_quotes(quotes: dict) -> None:
|
||||
"""직전 스캔이 모은 시세 — judge_view(관심종목 탭)가 라이브 재호출 없이 같은 값을 재사용."""
|
||||
if not quotes: # 시세 수집 실패(빈 dict)면 직전 정상값 보존 — 덮어쓰지 않음
|
||||
return
|
||||
try:
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
LAST_QUOTES_PATH.write_text(json.dumps(quotes, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
sys.stderr.write(f'[engine] last_quotes 저장 실패: {e}\n')
|
||||
|
||||
|
||||
def _load_last_quotes() -> dict | None:
|
||||
"""직전 스캔 시세. 없으면 None → 호출부가 라이브 폴백."""
|
||||
try:
|
||||
return json.loads(LAST_QUOTES_PATH.read_text())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _gather(extra_codes=None, quotes=None):
|
||||
"""파라미터 무관한 라이브 데이터 1회 수집 (메인·변이 공유). 종목·시세·시장매핑.
|
||||
|
||||
extra_codes: universe에 없지만 청산 판단을 위해 포함해야 할 보유 코드(수동 삭제된 보유분).
|
||||
quotes: 주어지면 라이브 시세 호출을 건너뛰고 그대로 사용 (judge_view 가 직전 스캔 시세 재사용).
|
||||
"""
|
||||
data.reset_book_memo() # 호가 공유 캐시 리셋 — 이번 스캔의 체결가를 전 선수가 공유
|
||||
_SCAN_CANDLES.clear()
|
||||
@@ -241,6 +264,7 @@ def _gather(extra_codes=None):
|
||||
uni = uni + [{'code': c, 'name': '', 'sources': ['held']}]
|
||||
codes.append(c)
|
||||
seen.add(c)
|
||||
if not quotes: # None(미지정) 또는 빈 dict(저장본 비었음)면 라이브 수집
|
||||
quotes = data.batch_quotes(codes)
|
||||
cmkt = universe.code_market_map()
|
||||
return uni, quotes, cmkt
|
||||
@@ -286,7 +310,8 @@ def _run_scan(pf, uni, quotes, cmkt, now, execute: bool = True) -> dict:
|
||||
flow = data.flow_net(data.investor_flow(code))
|
||||
anl = data.analyst(code, cur_price)
|
||||
dec = signals.evaluate_holding(pf.positions[code], ind, flow, anl,
|
||||
held_days=_held_days(pf.positions[code], now))
|
||||
held_days=_held_days(pf.positions[code], now),
|
||||
market_ok=mkt_ok.get(cmkt.get(code, ''), True))
|
||||
pf.apply_position_update(code, dec['position_update'])
|
||||
dec.update({k: pf.positions[code].get(k) for k in
|
||||
('entry_price', 'qty', 'stop', 'target', 'trailing_on', 'entry_at')})
|
||||
@@ -305,7 +330,9 @@ def _run_scan(pf, uni, quotes, cmkt, now, execute: bool = True) -> dict:
|
||||
# 새 평단 기준 손절·목표 재산정 (손절은 위로만 래칫)
|
||||
new_entry = pf.positions[code]['entry_price']
|
||||
nstop, ntarget = signals.compute_stop_target(new_entry, ind['atr'], ind['recent_low'])
|
||||
pf.positions[code]['stop'] = max(pf.positions[code]['stop'], nstop)
|
||||
# 추격매수(up)=손절 위로만 래칫 / 물타기(down)=낮아진 평단 기준으로 손절 재설정
|
||||
pf.positions[code]['stop'] = (nstop if dec.get('add_kind') == 'down'
|
||||
else max(pf.positions[code]['stop'], nstop))
|
||||
pf.positions[code]['target'] = ntarget
|
||||
buys.append(rec)
|
||||
dec.update({'fill_price': fill, 'entry_price': new_entry,
|
||||
@@ -396,7 +423,9 @@ def judge_view(vid: str) -> dict | None:
|
||||
pf = Portfolio.load(pp, tp)
|
||||
try:
|
||||
backtest.apply_params(params)
|
||||
uni, quotes, cmkt = _gather(set(pf.positions))
|
||||
# 직전 스캔이 저장한 시세 재사용 — 라이브 재호출 없이 보유종목 가격과 같은 시점으로 통일.
|
||||
# 스캔 이력이 아직 없으면(None) _gather 가 라이브 폴백.
|
||||
uni, quotes, cmkt = _gather(set(pf.positions), quotes=_load_last_quotes())
|
||||
return _run_scan(pf, uni, quotes, cmkt, now, execute=False)
|
||||
finally:
|
||||
backtest.apply_params(config.load_params()) # 전역 config 원복 (웹 데몬 잔류 방지)
|
||||
@@ -422,6 +451,7 @@ def scan(force: bool = False) -> dict:
|
||||
return {'skipped': True, 'reason': '장외/휴장', 'at': now.isoformat()}
|
||||
pf = Portfolio.load()
|
||||
uni, quotes, cmkt = _gather(set(pf.positions)) # 보유분은 universe에서 빠져도 청산 위해 포함
|
||||
_save_last_quotes(quotes)
|
||||
snap = _run_scan(pf, uni, quotes, cmkt, now)
|
||||
_attach_benchmark(snap, pf, now)
|
||||
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -445,6 +475,7 @@ def scan_all(force: bool = False) -> dict:
|
||||
for vpf in vpfs.values():
|
||||
held_union |= set(vpf.positions)
|
||||
uni, quotes, cmkt = _gather(held_union)
|
||||
_save_last_quotes(quotes)
|
||||
|
||||
# 기준 판단 (params.json) — 2026-06-10 기준전략 폐지: 매매 없이 판단만(last_scan = 화면·상태점용).
|
||||
# 실제 매매는 전부 변이들이 한다 (메인 계좌 동결).
|
||||
|
||||
@@ -266,11 +266,12 @@ def evaluate_candidate(code, name, sources, ind, flow, analyst, market_ok) -> di
|
||||
'reason': f'눌림목 대기 — 지정가 {limit:,}'}
|
||||
|
||||
|
||||
def evaluate_holding(position, ind, flow, analyst, held_days=None) -> dict:
|
||||
def evaluate_holding(position, ind, flow, analyst, held_days=None, market_ok=True) -> dict:
|
||||
"""보유 종목 판단. 우선순위: 손절/추세이탈(전량) → 시간손절 → 분할익절(scale_out) → 추격매수(add) → 유지.
|
||||
|
||||
position 의 stop/peak/trailing/scaled_out 갱신값도 position_update 로 함께 반환.
|
||||
held_days: 보유 경과 일수(달력). None이면 시간손절 미적용(엔진·백테스트가 산정해 전달).
|
||||
market_ok: 시장 강세 여부(물타기 허용 판단용, 백테스트는 중립 True).
|
||||
"""
|
||||
price = ind['price']
|
||||
entry = position['entry_price']
|
||||
@@ -314,6 +315,23 @@ def evaluate_holding(position, ind, flow, analyst, held_days=None) -> dict:
|
||||
'price': price, 'checks': checks, 'action': None, 'state': 'HOLD',
|
||||
'position_update': upd, 'pnl_pct': pnl_pct}
|
||||
|
||||
# 0. 물타기(애버리징) — 켜진 변이만. 하락 시 손절 대신 추가매수로 평단↓ (한도·강세장·비급락 조건).
|
||||
# 당일 단일 급락(-CRASH%↓)은 물타기 금지→손절 우선(그림 #3), 약세장도 금지(그림 #4).
|
||||
avg_on = getattr(config, 'AVERAGING_ENTRY', 0)
|
||||
if avg_on:
|
||||
prev_close = ind.get('prev_close') or price
|
||||
today_chg = (price / prev_close - 1) if prev_close else 0
|
||||
crash = today_chg <= -getattr(config, 'CRASH_DROP_PCT', 0.10)
|
||||
avg_trigger = last_add * (1 - getattr(config, 'AVERAGING_DROP_PCT', 0.10))
|
||||
budget_left = tranches < config.ENTRY_TRANCHES
|
||||
# 트레일링/익절 전 + 한도 남음 + 강세장 + 비급락 + 직전매수가 -X% 도달 → 물타기
|
||||
if (budget_left and market_ok and not crash and not trailing_on and not scaled_out
|
||||
and price <= avg_trigger):
|
||||
return {**base, 'state': 'ADD', 'action': 'add', 'add_kind': 'down',
|
||||
'reason': f'물타기 — {tranches + 1}/{config.ENTRY_TRANCHES}차 (평단↓, -{config.AVERAGING_DROP_PCT*100:.0f}%)'}
|
||||
# 물타기 한도 소진(또는 급락/약세장)인데 손절선 깨짐 → 최종 손절
|
||||
# (한도 남았는데 급락이면 아래 일반 손절로 자연 처리)
|
||||
|
||||
# 1. 손절·추세이탈 — 전량
|
||||
if price <= stop:
|
||||
reason = '트레일링 익절' if (trailing_on and price > entry) else '손절'
|
||||
|
||||
@@ -68,25 +68,31 @@ PTR_HTML = """
|
||||
# 전체 리로드여도 보던 화면이 유지되게 한다 (자동·당겨서·수동 새로고침 공통).
|
||||
REFRESH_JS = """
|
||||
<script>(function(){
|
||||
var TABS=['lab','watch','market'],SUBS=['bt','cmp','day'];
|
||||
var TABS=['lab','watch','market'],SUBS=['bt','cmp','day'],WSUBS=['mine','auto'];
|
||||
var p=new URLSearchParams(location.search);
|
||||
var t=p.get('t'),s=p.get('s'),vw=p.get('view');
|
||||
var t=p.get('t'),s=p.get('s'),w=p.get('w'),vw=p.get('view');
|
||||
if(SUBS.indexOf(t)>=0){s=t;t='lab';} /* 레거시 ?t=bt/cmp 하위호환 */
|
||||
if(t==='acct'||t==='tune')t='lab'; /* 삭제된 탭 하위호환 */
|
||||
if(t){var r=document.getElementById('tab-'+t); if(r)r.checked=true;}
|
||||
if(s){var rs=document.getElementById('sub-'+s); if(rs)rs.checked=true;}
|
||||
if(w){var rw=document.getElementById('wsub-'+w); if(rw)rw.checked=true;}
|
||||
/* 탭 라디오 ↔ URL 동기화 — 어떤 새로고침(당겨서/브라우저/reload)이든 보던 탭으로 돌아오게 */
|
||||
function simURL(){
|
||||
var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;});
|
||||
var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;});
|
||||
var u='/?t='+(cur||'lab')+(cur==='lab'?'&s='+(sub||'cmp'):'')+((cur==='watch'&&vw)?'&view='+encodeURIComponent(vw):'');
|
||||
var u='/?t='+(cur||'lab')+(cur==='lab'?'&s='+(sub||'cmp'):'');
|
||||
if(cur==='watch'){
|
||||
var wc=WSUBS.find(function(x){var e=document.getElementById('wsub-'+x);return e&&e.checked;});
|
||||
u+='&w='+(wc||'mine');
|
||||
if(vw)u+='&view='+encodeURIComponent(vw);
|
||||
}
|
||||
if(cur==='lab'&&sub==='day'){var dd=new URLSearchParams(location.search).get('d');
|
||||
if(dd)u+='&d='+encodeURIComponent(dd);} /* 거래목록 선택 날짜 보존 */
|
||||
return u;
|
||||
}
|
||||
window._simURL=simURL;
|
||||
function syncUrl(){try{history.replaceState(null,'',simURL());}catch(e){}}
|
||||
document.querySelectorAll('input[name="tab"],input[name="subtab"]').forEach(function(r2){r2.addEventListener('change',syncUrl);});
|
||||
document.querySelectorAll('input[name="tab"],input[name="subtab"],input[name="wsub"]').forEach(function(r2){r2.addEventListener('change',syncUrl);});
|
||||
syncUrl();
|
||||
function banner(msg,color){var b=document.createElement('div');b.className='saved-banner';
|
||||
if(color)b.style.background=color;b.textContent=msg;document.body.appendChild(b);
|
||||
@@ -125,9 +131,7 @@ REFRESH_JS = """
|
||||
var ae=document.activeElement;
|
||||
if(ae&&(ae.tagName==='INPUT'||ae.tagName==='TEXTAREA')) return; /* 입력 중 멈춤 */
|
||||
saveState();
|
||||
var cur=TABS.find(function(x){var e=document.getElementById('tab-'+x);return e&&e.checked;});
|
||||
var sub=SUBS.find(function(x){var e=document.getElementById('sub-'+x);return e&&e.checked;});
|
||||
location.href='/?t='+(cur||'lab')+(cur==='lab'?'&s='+(sub||'cmp'):'')+((cur==='watch'&&vw)?'&view='+encodeURIComponent(vw):'');
|
||||
location.href=simURL();
|
||||
},60000);
|
||||
})();</script>
|
||||
"""
|
||||
@@ -866,6 +870,19 @@ def _immediate_chip_html(p) -> str:
|
||||
f'data-detail="{_esc(desc)}">즉시</span>')
|
||||
|
||||
|
||||
def _avg_chip_html(p) -> str:
|
||||
"""물타기(애버리징) 칩 — 보유 중 하락 시 손절 대신 추가매수로 평단 낮추는 변이. 아니면 빈 문자열."""
|
||||
if not int((p or {}).get('AVERAGING_ENTRY', 0)):
|
||||
return ''
|
||||
drop = (p or {}).get('AVERAGING_DROP_PCT', 0.10)
|
||||
desc = ('물타기(애버리징) 변이 — 우량주(추세·정배열 통과)를 산 뒤 직전 매수가보다 '
|
||||
f'-{drop*100:.0f}% 떨어지면, 손절하지 않고 추가매수해 평균단가를 낮춰요(종목당 10% 한도). '
|
||||
'단 ①당일 -10%↓ 급락이면 물타기 말고 손절 ②약세장이면 물타기 금지 — 떨어지는 칼·하락장 물타기는 피해요. '
|
||||
'(손절형 기존 전략과 라이브 비교 중)')
|
||||
return (f'<span class="bic bic-avg" data-title="물타기(애버리징)" '
|
||||
f'data-detail="{_esc(desc)}">물타기</span>')
|
||||
|
||||
|
||||
def _backtest_panel() -> str:
|
||||
"""백테스트 결과 — 유형별(세부 라벨) 대표(1위) 조합 요약. 비교군은 이 대표들로 자동 구성되므로
|
||||
수동 추가/제거 없이 읽기 전용 요약만 보여준다."""
|
||||
@@ -1122,8 +1139,8 @@ def _compare_panel() -> str:
|
||||
bt_row = ''
|
||||
rows += (
|
||||
f'<details class="row" data-reg="{i}" data-rank="{rank_of[v["id"]]}" data-bt="{bt_attr}" '
|
||||
f'data-held="{s.get("open_positions", 0)}" data-trades="{s.get("closed_trades", 0)}"><summary>'
|
||||
f'<span class="vid">{rank_of[v["id"]]}위</span>{_bear_chip_html(mp)}{_relax_chip_html(mp)}{_immediate_chip_html(mp)} <span class="muted">{_vid_disp(vid)}</span> '
|
||||
f'data-ret="{ret}" data-held="{s.get("open_positions", 0)}" data-trades="{s.get("closed_trades", 0)}"><summary>'
|
||||
f'<span class="vid">{rank_of[v["id"]]}위</span>{_bear_chip_html(mp)}{_relax_chip_html(mp)}{_immediate_chip_html(mp)}{_avg_chip_html(mp)} <span class="muted">{_vid_disp(vid)}</span> '
|
||||
f'<span class="nm">{_esc(_cat_name(mp))}</span>{tag}'
|
||||
f'<span class="rt"><span class="muted">🛒{s.get("open_positions", 0)}</span> '
|
||||
f'<b style="color:{rc}">{ret:+g}%</b></span></summary>'
|
||||
@@ -1155,29 +1172,45 @@ def _compare_panel() -> str:
|
||||
f'</div></details>'
|
||||
)
|
||||
sortbar = ('<div class="cmp-sortbar" id="cmp-sortbar">'
|
||||
'<button type="button" data-s="rank">손익순</button>'
|
||||
'<button type="button" data-s="bt">평가순</button>'
|
||||
'<button type="button" data-s="held">보유순</button>'
|
||||
'<button type="button" data-s="trades">거래순</button></div>')
|
||||
'<button type="button" data-s="rank" data-attr="data-ret">손익순</button>'
|
||||
'<button type="button" data-s="bt" data-attr="data-bt">평가순</button>'
|
||||
'<button type="button" data-s="held" data-attr="data-held">보유순</button>'
|
||||
'<button type="button" data-s="trades" data-attr="data-trades">거래순</button></div>')
|
||||
# 각 버튼: 한 번 누를 때마다 오름/내림 토글. 기본 방향 — 손익/보유/거래=내림(desc), 평가(백테스트순위)=오름(asc)
|
||||
sort_js = """
|
||||
<script>(function(){
|
||||
var bar=document.getElementById('cmp-sortbar'),box=document.getElementById('cmp-rows');
|
||||
if(!bar||!box)return;
|
||||
var ATTR={rank:'data-ret',bt:'data-bt',held:'data-held',trades:'data-trades'};
|
||||
var LBL={rank:'손익순',bt:'평가순',held:'보유순',trades:'거래순'};
|
||||
var DEFDIR={rank:'desc',bt:'asc',held:'desc',trades:'desc'}; // 기본 방향
|
||||
function num(el,a){return parseFloat(el.getAttribute(a))||0;}
|
||||
var CMP={rank:function(a,b){return num(a,'data-rank')-num(b,'data-rank');},
|
||||
bt:function(a,b){return num(a,'data-bt')-num(b,'data-bt');},
|
||||
held:function(a,b){return num(b,'data-held')-num(a,'data-held');},
|
||||
trades:function(a,b){return num(b,'data-trades')-num(a,'data-trades');}};
|
||||
function apply(m){
|
||||
var f=CMP[m]; if(!f)return;
|
||||
Array.prototype.slice.call(box.children).sort(f).forEach(function(r){box.appendChild(r);});
|
||||
bar.querySelectorAll('button').forEach(function(b){b.classList.toggle('active',b.dataset.s===m);});
|
||||
try{sessionStorage.setItem('cmpSort',m);}catch(e){}
|
||||
function apply(m,dir){
|
||||
var at=ATTR[m]; if(!at)return;
|
||||
var mul=(dir==='asc')?1:-1;
|
||||
Array.prototype.slice.call(box.children)
|
||||
.sort(function(a,b){return mul*(num(a,at)-num(b,at));})
|
||||
.forEach(function(r){box.appendChild(r);});
|
||||
bar.querySelectorAll('button').forEach(function(b){
|
||||
var on=b.dataset.s===m;
|
||||
b.classList.toggle('active',on);
|
||||
b.textContent=LBL[b.dataset.s]+(on?(dir==='asc'?' ↑':' ↓'):'');
|
||||
});
|
||||
try{sessionStorage.setItem('cmpSort',m);sessionStorage.setItem('cmpDir',dir);}catch(e){}
|
||||
}
|
||||
bar.addEventListener('click',function(e){var b=e.target.closest('button');if(b)apply(b.dataset.s);});
|
||||
var m='rank';try{m=sessionStorage.getItem('cmpSort')||'rank';}catch(e){}
|
||||
if(!CMP[m])m='rank';
|
||||
apply(m);
|
||||
bar.addEventListener('click',function(e){
|
||||
var b=e.target.closest('button'); if(!b)return;
|
||||
var m=b.dataset.s, cur=sessionStorage.getItem('cmpSort'), curdir=sessionStorage.getItem('cmpDir');
|
||||
var dir;
|
||||
if(cur===m){dir=(curdir==='asc')?'desc':'asc';} // 같은 버튼 재클릭 → 방향 토글
|
||||
else{dir=DEFDIR[m];} // 다른 버튼 → 그 버튼 기본 방향
|
||||
apply(m,dir);
|
||||
});
|
||||
var m='rank',dir;
|
||||
try{m=sessionStorage.getItem('cmpSort')||'rank';dir=sessionStorage.getItem('cmpDir');}catch(e){}
|
||||
if(!ATTR[m])m='rank';
|
||||
if(!dir)dir=DEFDIR[m];
|
||||
apply(m,dir);
|
||||
})();</script>"""
|
||||
fav_js = ("""
|
||||
<script>(function(){
|
||||
@@ -1294,7 +1327,7 @@ h1{font-size:17px;margin:10px 0 2px}h2{font-size:14px;margin:22px 0 8px;color:#c
|
||||
.vid{background:#1e293b;color:#7dd3fc;border-radius:5px;padding:1px 6px;font-size:11px;font-weight:700}
|
||||
.bic{border-radius:5px;padding:1px 5px;font-size:10px;font-weight:700;margin-left:4px}
|
||||
.bic0{background:#334155;color:#cbd5e1}.bic1{background:#1e3a5f;color:#7dd3fc}.bic2{background:#4a2e12;color:#fbbf24}
|
||||
.bic-relax{background:#3b1d4a;color:#d8b4fe}.bic-now{background:#0c3a2e;color:#6ee7b7}
|
||||
.bic-relax{background:#3b1d4a;color:#d8b4fe}.bic-now{background:#0c3a2e;color:#6ee7b7}.bic-avg{background:#3a2a0c;color:#fcd34d}
|
||||
.cmp-btlink{background:#0f2a3f;color:#7dd3fc;border:1px solid #1e3a5f;border-radius:6px;padding:3px 8px;font-size:11px;font-weight:600;display:inline-block}
|
||||
.note{white-space:pre-wrap;line-height:1.65;font-size:13px;color:#cbd5e1}
|
||||
ul.an{margin:6px 0;padding-left:18px;line-height:1.7;font-size:13px}
|
||||
@@ -1425,7 +1458,7 @@ td:first-child,th:first-child{text-align:left}
|
||||
<title>{_esc(vid)} 전략 상세</title>{style}</head><body><div id="page">
|
||||
{back}
|
||||
<h1><span class="vid">{rank or "—"}위</span> <span class="muted">{_vid_disp(vid)}</span> {_esc(_cat_name(params))}
|
||||
<span class="bic bic{bm}">{_BEAR_WORD[bm]}</span>{_relax_chip_html(params)}{_immediate_chip_html(params)} {icon}</h1>
|
||||
<span class="bic bic{bm}">{_BEAR_WORD[bm]}</span>{_relax_chip_html(params)}{_immediate_chip_html(params)}{_avg_chip_html(params)} {icon}</h1>
|
||||
<div class="sub">{_esc(_market_fit_summary(params))} · 약세장대처: {_BEAR_NAME[bm]}</div>
|
||||
|
||||
<div class="card">{notes_html}</div>
|
||||
@@ -1825,7 +1858,8 @@ def _decision_card(d, watch_n: int = 0, held_n: int = 0, acc_detail: str = '', b
|
||||
|
||||
|
||||
def render_html(view: str | None = None, day_date: str | None = None,
|
||||
tab: str | None = None, sub: str | None = None) -> str:
|
||||
tab: str | None = None, sub: str | None = None,
|
||||
wsub: str | None = None) -> str:
|
||||
snap = _load(config.LAST_SCAN_PATH, {})
|
||||
from . import variants as vmod
|
||||
_cv = [c for c in (_load(vmod.COMPARE_PATH, {}) or {}).get('variants', [])
|
||||
@@ -1916,7 +1950,12 @@ def render_html(view: str | None = None, day_date: str | None = None,
|
||||
|
||||
# 상태 그룹 = 전체 전략(메인+변이) 합산 — 한 계좌라도 보유→보유 / 매수직전→매수 / 대기→대기.
|
||||
# 아무 계좌도 안 보면 기준전략(M) 판정. 카드 체크리스트·사유는 기준전략 시각 유지(합산 정보는 사유 앞에 표시).
|
||||
by_state = {}
|
||||
# 출처(origin) 맵 — auto(자동편입) 종목은 별도 섹션으로 분리
|
||||
_wl0 = umod._load_watchlist()
|
||||
origin_map = {c: e.get('origin', '') for c, e in _wl0.items()}
|
||||
|
||||
by_state = {} # 내 종목(워치·관심·보유·수동)
|
||||
by_state_auto = {} # 자동편입(auto) — 하단 별도
|
||||
excluded = [] # 매매 안 하는 종목(ETF/ETN 등) → 하단 제외목록
|
||||
for d in decisions:
|
||||
if umod.is_etf(d.get('name', '')):
|
||||
@@ -1940,18 +1979,30 @@ def render_html(view: str | None = None, day_date: str | None = None,
|
||||
elif watch_n.get(code):
|
||||
bits.append(f'{watch_n[code]}개 전략 매수 대기')
|
||||
d = {**d, 'reason': ' · '.join(bits) + f' — {view_label}: {d.get("reason", "")}'}
|
||||
by_state.setdefault(st, []).append(d)
|
||||
# 출처대로만 분류 — auto(자동편입)는 변이가 페이퍼로 보유 중이어도 자동편입 탭에 (보유는 그 안 '보유' 그룹에 표시)
|
||||
tgt = by_state_auto if origin_map.get(code) == 'auto' else by_state
|
||||
tgt.setdefault(st, []).append(d)
|
||||
|
||||
judge_html = ''
|
||||
def _state_blocks(bucket):
|
||||
# 상태별 접이식 — 액션 상태(보유·추격·매수·매도·대기)는 펼침, 제외·데이터없음은 접힘(노이즈)
|
||||
_open = {'HOLD', 'ADD', 'BUY', 'SELL', 'WAIT'}
|
||||
out = ''
|
||||
for st in STATE_ORDER:
|
||||
items = by_state.get(st, [])
|
||||
items = bucket.get(st, [])
|
||||
if not items:
|
||||
continue
|
||||
label, color = STATE_BADGE.get(st, (st, '#888'))
|
||||
judge_html += f'<h3 class="grp"><span class="badge" style="background:{color}">{label}</span> {len(items)}</h3>'
|
||||
for d in items:
|
||||
judge_html += _decision_card(d, watch_n.get(d.get('code'), 0), held_n.get(d.get('code'), 0),
|
||||
cards = ''.join(_decision_card(d, watch_n.get(d.get('code'), 0), held_n.get(d.get('code'), 0),
|
||||
_acc_detail(d.get('code')), buy_n.get(d.get('code'), 0))
|
||||
for d in items)
|
||||
op = ' open' if st in _open else ''
|
||||
out += (f'<details class="row state-grp"{op}><summary>'
|
||||
f'<span class="badge" style="background:{color}">{label}</span> {len(items)}</summary>'
|
||||
f'<div class="body">{cards}</div></details>')
|
||||
return out
|
||||
|
||||
mine_n = sum(len(v) for v in by_state.values())
|
||||
judge_html = _state_blocks(by_state)
|
||||
|
||||
# 관찰목록 중 아직 스캔 안 된(방금 추가 등) 종목 → '스캔 대기'로 즉시 표시
|
||||
wl = umod._load_watchlist()
|
||||
@@ -1971,7 +2022,12 @@ def render_html(view: str | None = None, day_date: str | None = None,
|
||||
f'<span class="price muted">다음 스캔 대기</span>{del_btn}</div></div>'
|
||||
)
|
||||
|
||||
# 매매 안 하는 종목(ETF/ETN 등) → 하단 접이식 제외목록
|
||||
# 자동편입(auto) 종목 → 상단 서브탭 '자동편입'으로 분리 (별도 HTML)
|
||||
auto_n = sum(len(v) for v in by_state_auto.values())
|
||||
judge_html_auto = (_state_blocks(by_state_auto) if auto_n else
|
||||
'<div class="empty">자동편입된 종목이 없어요 (매일 장 마감 후 키움 순위정보로 편입)</div>')
|
||||
|
||||
# 매매 안 하는 종목(ETF/ETN 등) → 하단 접이식 제외목록 (내 종목 쪽에 둠)
|
||||
if excluded:
|
||||
judge_html += (
|
||||
f'<details class="row" style="margin-top:16px"><summary>'
|
||||
@@ -1994,11 +2050,13 @@ def render_html(view: str | None = None, day_date: str | None = None,
|
||||
_vp = vv.get('params') or {}
|
||||
_rx = ' 완화' if (not int(_vp.get('TREND_SLOPE_GATE', 1)) or not int(_vp.get('MID_ARRAY_GATE', 1))) else ''
|
||||
_im = ' 즉시' if int(_vp.get('IMMEDIATE_ENTRY', 0)) else ''
|
||||
_av = ' 물타기' if int(_vp.get('AVERAGING_ENTRY', 0)) else ''
|
||||
opts += (f'<option value="{_esc(vv["id"])}"{_sel}>{_fav}{_rk(vv["id"])}{_vid_disp(vv["id"])} '
|
||||
f'[{_BEAR_WORD[_bear_mode(_vp)]}{_rx}{_im}] · {_esc(_cat_name(_vp))}</option>')
|
||||
f'[{_BEAR_WORD[_bear_mode(_vp)]}{_rx}{_im}{_av}] · {_esc(_cat_name(_vp))}</option>')
|
||||
view_sel = (f'<div class="sub" style="margin:4px 0 8px">체크리스트 시각: '
|
||||
f'<select id="judge-view" '
|
||||
f'onchange="location.href=\'/?t=watch&view=\'+encodeURIComponent(this.value)">{opts}</select></div>')
|
||||
f'onchange="var _w=document.getElementById(\'wsub-auto\').checked?\'auto\':\'mine\';'
|
||||
f'location.href=\'/?t=watch&w=\'+_w+\'&view=\'+encodeURIComponent(this.value)">{opts}</select></div>')
|
||||
|
||||
mkt = snap.get('market_ok', {})
|
||||
mkt_h = ' · '.join(f'{k} {"양호" if v else "약세"}' for k, v in mkt.items()) or '—'
|
||||
@@ -2010,7 +2068,8 @@ def render_html(view: str | None = None, day_date: str | None = None,
|
||||
# 활성 탭/서브탭을 서버에서 미리 checked — JS가 로드 후 바꾸며 생기던 깜빡임 제거.
|
||||
tab = tab if tab in ('lab', 'watch', 'market') else 'lab'
|
||||
sub = sub if sub in ('cmp', 'bt', 'day') else 'cmp'
|
||||
ck = {f'tab-{tab}': ' checked', f'sub-{sub}': ' checked'}
|
||||
wsub = wsub if wsub in ('mine', 'auto') else 'mine'
|
||||
ck = {f'tab-{tab}': ' checked', f'sub-{sub}': ' checked', f'wsub-{wsub}': ' checked'}
|
||||
def _ck(rid):
|
||||
return ck.get(rid, '')
|
||||
|
||||
@@ -2028,6 +2087,12 @@ h1{{font-size:16px;margin:0 0 2px}} .sub{{color:#9ca3af;font-size:11px;margin:0
|
||||
.kpi-sub{{color:#6b7280;font-size:10px;margin-top:2px}}
|
||||
h2{{font-size:15px;margin:22px 0 8px;border-bottom:1px solid #1f2937;padding-bottom:6px}}
|
||||
h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
||||
.state-grp{{margin:6px 0;border-top:none}}
|
||||
.state-grp>summary{{padding:8px 2px;font-size:13px;font-weight:600;list-style:none;display:flex;align-items:center;gap:6px}}
|
||||
.state-grp>summary::-webkit-details-marker{{display:none}}
|
||||
.state-grp>summary::before{{content:'▸';color:#6b7280;font-size:11px}}
|
||||
.state-grp[open]>summary::before{{content:'▾'}}
|
||||
.state-grp>.body{{padding:2px 0 4px}}
|
||||
.badge{{color:#fff;border-radius:6px;padding:1px 8px;font-size:12px;font-weight:600}}
|
||||
.row{{background:#141b24;border:1px solid #1f2937;border-radius:10px;margin-bottom:8px;overflow:hidden}}
|
||||
.row>summary{{list-style:none;cursor:pointer;padding:10px 12px;display:flex;flex-wrap:nowrap;align-items:center;gap:8px;font-size:13px;overflow:hidden}}
|
||||
@@ -2115,6 +2180,7 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
||||
.bic2{{background:#4a2e12;color:#fbbf24}}
|
||||
.bic-relax{{background:#3b1d4a;color:#d8b4fe}}
|
||||
.bic-now{{background:#0c3a2e;color:#6ee7b7}}
|
||||
.bic-avg{{background:#3a2a0c;color:#fcd34d}}
|
||||
.cmp-sortbar{{display:flex;gap:6px;margin:0 0 10px}}
|
||||
.cmp-sortbar button{{flex:1;background:#141b24;border:1px solid #1f2937;border-radius:8px;color:#9ca3af;font-size:11px;font-weight:600;padding:7px 4px;cursor:pointer}}
|
||||
.cmp-sortbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||||
@@ -2125,7 +2191,7 @@ h3.grp{{font-size:13px;margin:16px 0 8px;color:#cbd5e1}}
|
||||
#chk-toast .ct-t{{font-size:15px;font-weight:700;color:#fff;margin-bottom:8px}}
|
||||
#chk-toast .ct-d{{font-size:13px;line-height:1.6;color:#cbd5e1;white-space:pre-line}}
|
||||
#chk-toast .ct-x{{margin-top:14px;width:100%;background:#1e3a8a;color:#fff;border:1px solid #2952cc;border-radius:9px;padding:10px;font-size:13px;font-weight:600;cursor:pointer}}
|
||||
input[name="tab"],input[name="subtab"]{{display:none}}
|
||||
input[name="tab"],input[name="subtab"],input[name="wsub"]{{display:none}}
|
||||
.hd{{position:sticky;top:0;z-index:30;background:#0b0f14;padding:6px 0;border-bottom:1px solid #161e29}}
|
||||
#page.dragging .hd{{position:static}}
|
||||
.tabs{{display:flex;gap:6px;margin:0}}
|
||||
@@ -2138,6 +2204,9 @@ input[name="tab"],input[name="subtab"]{{display:none}}
|
||||
#sub-cmp:checked~#p-lab label[for="sub-cmp"],#sub-bt:checked~#p-lab label[for="sub-bt"]{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||||
#p-lab .subpanel{{display:none}}
|
||||
#p-lab .subpanel h2{{margin-top:4px}}
|
||||
#p-watch .subpanel{{display:none}}
|
||||
#wsub-mine:checked~#p-watch #pw-mine,#wsub-auto:checked~#p-watch #pw-auto{{display:block}}
|
||||
#wsub-mine:checked~#p-watch label[for="wsub-mine"],#wsub-auto:checked~#p-watch label[for="wsub-auto"]{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||||
.sortbar{{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 12px}}
|
||||
.sortbar button{{background:#141b24;border:1px solid #1f2937;border-radius:7px;color:#9ca3af;font-size:12px;font-weight:600;padding:7px 10px;cursor:pointer;font-variant-numeric:tabular-nums}}
|
||||
.sortbar button.active{{background:#15406e;color:#fff;border-color:#2952cc}}
|
||||
@@ -2208,6 +2277,8 @@ input[name="tab"],input[name="subtab"]{{display:none}}
|
||||
<input type="radio" name="subtab" id="sub-cmp"{_ck('sub-cmp')}>
|
||||
<input type="radio" name="subtab" id="sub-bt"{_ck('sub-bt')}>
|
||||
<input type="radio" name="subtab" id="sub-day"{_ck('sub-day')}>
|
||||
<input type="radio" name="wsub" id="wsub-mine"{_ck('wsub-mine')}>
|
||||
<input type="radio" name="wsub" id="wsub-auto"{_ck('wsub-auto')}>
|
||||
<header class="hd">
|
||||
<h1> 레이 자동매매 시뮬레이션 <span id="health" class="hdot {hcls}" data-detail="{_esc(hdetail)}" role="button" tabindex="0" title="시뮬 상태 보기"></span></h1>
|
||||
<div class=sub>마지막 스캔 {scanned or '—'} · 시장 {mkt_h}</div>
|
||||
@@ -2222,7 +2293,11 @@ input[name="tab"],input[name="subtab"]{{display:none}}
|
||||
<div class="sub">상태 그룹은 전체 전략(메인+변이) 합산 — 한 전략이라도 보유/매수대기면 그 그룹으로 · 배지: 🟢매수준비 · 🟡대기 · 🛒보유 전략 수 (탭하면 어떤 전략인지)</div>
|
||||
<div class="sub">종목명 일부만 입력해도 후보가 떠요 · 후보를 누르면 추가 · 자산(비하이브 워치·관심·보유)은 자동 편입 · 삭제는 카드의 ✕ (수동만) · 보유 중이면 삭제해도 청산까지 추적</div>
|
||||
{view_sel}
|
||||
{judge_html}
|
||||
<nav class="subtabs" id="wsubtabs">
|
||||
<label for="wsub-mine">📌 내 종목 ({mine_n})</label>
|
||||
<label for="wsub-auto">🤖 자동편입 ({auto_n})</label></nav>
|
||||
<div class="subpanel" id="pw-mine">{judge_html}</div>
|
||||
<div class="subpanel" id="pw-auto">{judge_html_auto}</div>
|
||||
</section>
|
||||
<section class="panel" id="p-market">
|
||||
<h2>시장 동향</h2>{market_html}
|
||||
@@ -2278,12 +2353,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
_dd = (_qs.get('d') or [None])[0]
|
||||
_tab = (_qs.get('t') or [None])[0]
|
||||
_sub = (_qs.get('s') or [None])[0]
|
||||
_wsub = (_qs.get('w') or [None])[0]
|
||||
# 레거시 ?t=bt/cmp/day (서브탭이 t로 옴) 하위호환 — REFRESH_JS와 동일 규칙
|
||||
if _tab in ('bt', 'cmp', 'day'):
|
||||
_sub, _tab = _tab, 'lab'
|
||||
if _tab in ('acct', 'tune'):
|
||||
_tab = 'lab'
|
||||
body = render_html(view=_view, day_date=_dd, tab=_tab, sub=_sub).encode('utf-8')
|
||||
body = render_html(view=_view, day_date=_dd, tab=_tab, sub=_sub, wsub=_wsub).encode('utf-8')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', 'text/html; charset=utf-8')
|
||||
# 캐시 금지 — Safari 휴리스틱 캐싱이 옛 HTML/JS를 재사용해 탭 복원 로직이 구버전으로 돌던 문제 방지
|
||||
|
||||
@@ -40,6 +40,116 @@ def _label(params: dict) -> str:
|
||||
return '·'.join(f'{short.get(k, k)}{v:g}' for k, v in params.items())
|
||||
|
||||
|
||||
def _philosophy_presets() -> list[dict]:
|
||||
"""철학별 라이브 비교군 프리셋.
|
||||
|
||||
기존 sweep은 같은 규칙의 강약 조절이 많아 보유 종목이 겹치기 쉬웠다. 이 프리셋은
|
||||
수급 기간, 애널 게이트, 시장 방어, 보유 종목 수, 진입 방식까지 같이 바꿔 라이브 행동을
|
||||
더 벌리는 용도다. 주문 모듈과 무관한 페이퍼 시뮬 전용 정의다.
|
||||
"""
|
||||
base = {
|
||||
'RSI_OVERBOUGHT': 75,
|
||||
'RSI_OVERSOLD': 20,
|
||||
'MAX_HOLD_DAYS': 20,
|
||||
'BEAR_ENTRY_MODE': 1,
|
||||
'TREND_SLOPE_GATE': 1,
|
||||
'MID_ARRAY_GATE': 1,
|
||||
'IMMEDIATE_ENTRY': 0,
|
||||
'AVERAGING_ENTRY': 0,
|
||||
'ENTRY_TRANCHES': 2,
|
||||
'SCALE_OUT_FRAC': 0.0,
|
||||
}
|
||||
|
||||
def p(name: str, strength: str, **params):
|
||||
merged = dict(base)
|
||||
merged.update(params)
|
||||
return {'name': f'{name}·{strength}', 'params': merged}
|
||||
|
||||
return [
|
||||
p('대형주 안정형', '보수', SMA_LONG=60, RR_RATIO=1.5, STOP_ATR_MULT=2.5,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=1.3, TURNOVER_BREAKOUT_MULT=1.2,
|
||||
TURNOVER_OVERHEAT_MULT=4.0, FLOW_DAYS=10, ANALYST_MIN_UPSIDE_PCT=3.0,
|
||||
MAX_POSITIONS=12, RISK_PER_TRADE_PCT=0.005, BEAR_ENTRY_MODE=0),
|
||||
p('대형주 안정형', '기본', SMA_LONG=60, RR_RATIO=2.0, STOP_ATR_MULT=2.2,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=1.5, TURNOVER_BREAKOUT_MULT=1.3,
|
||||
TURNOVER_OVERHEAT_MULT=4.0, FLOW_DAYS=7, ANALYST_MIN_UPSIDE_PCT=5.0,
|
||||
MAX_POSITIONS=10, RISK_PER_TRADE_PCT=0.0075, BEAR_ENTRY_MODE=1),
|
||||
p('대형주 안정형', '공격', SMA_LONG=20, RR_RATIO=2.0, STOP_ATR_MULT=2.0,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=1.5, TURNOVER_BREAKOUT_MULT=1.5,
|
||||
TURNOVER_OVERHEAT_MULT=4.5, FLOW_DAYS=5, ANALYST_MIN_UPSIDE_PCT=5.0,
|
||||
MAX_POSITIONS=8, RISK_PER_TRADE_PCT=0.01, BEAR_ENTRY_MODE=1),
|
||||
|
||||
p('수급 추종형', '보수', SMA_LONG=60, RR_RATIO=2.0, STOP_ATR_MULT=2.0,
|
||||
PULLBACK_ATR_MULT=1.5, VOLUME_BREAKOUT_MULT=1.3, TURNOVER_BREAKOUT_MULT=1.2,
|
||||
FLOW_DAYS=10, ANALYST_MIN_UPSIDE_PCT=0.0, MAX_POSITIONS=8,
|
||||
RISK_PER_TRADE_PCT=0.006, BEAR_ENTRY_MODE=0),
|
||||
p('수급 추종형', '기본', SMA_LONG=20, RR_RATIO=2.0, STOP_ATR_MULT=1.8,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=1.3, TURNOVER_BREAKOUT_MULT=1.3,
|
||||
FLOW_DAYS=5, ANALYST_MIN_UPSIDE_PCT=0.0, MAX_POSITIONS=8,
|
||||
RISK_PER_TRADE_PCT=0.01, BEAR_ENTRY_MODE=1),
|
||||
p('수급 추종형', '공격', SMA_LONG=10, RR_RATIO=2.5, STOP_ATR_MULT=1.5,
|
||||
PULLBACK_ATR_MULT=0.8, VOLUME_BREAKOUT_MULT=1.2, TURNOVER_BREAKOUT_MULT=1.2,
|
||||
FLOW_DAYS=3, ANALYST_MIN_UPSIDE_PCT=-5.0, MAX_POSITIONS=6,
|
||||
RISK_PER_TRADE_PCT=0.0125, BEAR_ENTRY_MODE=1, IMMEDIATE_ENTRY=1),
|
||||
|
||||
p('돌파 모멘텀형', '보수', SMA_LONG=60, BREAKOUT_LOOKBACK=40, RR_RATIO=3.0,
|
||||
STOP_ATR_MULT=1.7, PULLBACK_ATR_MULT=0.0, VOLUME_BREAKOUT_MULT=2.5,
|
||||
TURNOVER_BREAKOUT_MULT=2.0, TURNOVER_OVERHEAT_MULT=5.0, FLOW_DAYS=5,
|
||||
ANALYST_MIN_UPSIDE_PCT=5.0, MAX_POSITIONS=6, RISK_PER_TRADE_PCT=0.006,
|
||||
BEAR_ENTRY_MODE=0),
|
||||
p('돌파 모멘텀형', '기본', SMA_LONG=20, BREAKOUT_LOOKBACK=20, RR_RATIO=3.0,
|
||||
STOP_ATR_MULT=1.5, PULLBACK_ATR_MULT=0.0, VOLUME_BREAKOUT_MULT=2.0,
|
||||
TURNOVER_BREAKOUT_MULT=1.8, TURNOVER_OVERHEAT_MULT=5.0, FLOW_DAYS=5,
|
||||
ANALYST_MIN_UPSIDE_PCT=5.0, MAX_POSITIONS=6, RISK_PER_TRADE_PCT=0.01,
|
||||
BEAR_ENTRY_MODE=1),
|
||||
p('돌파 모멘텀형', '공격', SMA_LONG=10, BREAKOUT_LOOKBACK=10, RR_RATIO=3.0,
|
||||
STOP_ATR_MULT=1.3, PULLBACK_ATR_MULT=0.0, VOLUME_BREAKOUT_MULT=1.7,
|
||||
TURNOVER_BREAKOUT_MULT=1.5, TURNOVER_OVERHEAT_MULT=6.0, FLOW_DAYS=3,
|
||||
ANALYST_MIN_UPSIDE_PCT=0.0, MAX_POSITIONS=5, RISK_PER_TRADE_PCT=0.0125,
|
||||
BEAR_ENTRY_MODE=1, IMMEDIATE_ENTRY=1),
|
||||
|
||||
p('눌림 반등형', '보수', SMA_LONG=60, RR_RATIO=1.8, STOP_ATR_MULT=2.5,
|
||||
PULLBACK_ATR_MULT=1.5, VOLUME_BREAKOUT_MULT=1.5, TURNOVER_BREAKOUT_MULT=1.2,
|
||||
FLOW_DAYS=10, RSI_OVERSOLD=25, ANALYST_MIN_UPSIDE_PCT=5.0,
|
||||
MAX_POSITIONS=10, RISK_PER_TRADE_PCT=0.006, BEAR_ENTRY_MODE=0),
|
||||
p('눌림 반등형', '기본', SMA_LONG=20, RR_RATIO=2.0, STOP_ATR_MULT=2.2,
|
||||
PULLBACK_ATR_MULT=1.5, VOLUME_BREAKOUT_MULT=1.3, TURNOVER_BREAKOUT_MULT=1.2,
|
||||
FLOW_DAYS=5, RSI_OVERSOLD=30, ANALYST_MIN_UPSIDE_PCT=5.0,
|
||||
MAX_POSITIONS=8, RISK_PER_TRADE_PCT=0.01, BEAR_ENTRY_MODE=0),
|
||||
p('눌림 반등형', '공격', SMA_LONG=20, RR_RATIO=2.5, STOP_ATR_MULT=2.0,
|
||||
PULLBACK_ATR_MULT=2.0, VOLUME_BREAKOUT_MULT=1.2, TURNOVER_BREAKOUT_MULT=1.1,
|
||||
FLOW_DAYS=3, RSI_OVERSOLD=35, ANALYST_MIN_UPSIDE_PCT=0.0,
|
||||
MAX_POSITIONS=6, RISK_PER_TRADE_PCT=0.0125, BEAR_ENTRY_MODE=1),
|
||||
|
||||
p('소형 성장형', '보수', SMA_LONG=60, RR_RATIO=3.0, STOP_ATR_MULT=1.8,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=2.5, TURNOVER_BREAKOUT_MULT=2.0,
|
||||
TURNOVER_OVERHEAT_MULT=6.0, FLOW_DAYS=5, ANALYST_MIN_UPSIDE_PCT=10.0,
|
||||
MAX_POSITIONS=5, RISK_PER_TRADE_PCT=0.004, BEAR_ENTRY_MODE=0),
|
||||
p('소형 성장형', '기본', SMA_LONG=20, RR_RATIO=3.0, STOP_ATR_MULT=1.5,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=2.0, TURNOVER_BREAKOUT_MULT=1.8,
|
||||
TURNOVER_OVERHEAT_MULT=6.0, FLOW_DAYS=5, ANALYST_MIN_UPSIDE_PCT=10.0,
|
||||
MAX_POSITIONS=5, RISK_PER_TRADE_PCT=0.006, BEAR_ENTRY_MODE=1),
|
||||
p('소형 성장형', '공격', SMA_LONG=10, RR_RATIO=3.5, STOP_ATR_MULT=1.3,
|
||||
PULLBACK_ATR_MULT=0.8, VOLUME_BREAKOUT_MULT=1.7, TURNOVER_BREAKOUT_MULT=1.5,
|
||||
TURNOVER_OVERHEAT_MULT=7.0, FLOW_DAYS=3, ANALYST_MIN_UPSIDE_PCT=5.0,
|
||||
MAX_POSITIONS=4, RISK_PER_TRADE_PCT=0.008, BEAR_ENTRY_MODE=1,
|
||||
IMMEDIATE_ENTRY=1),
|
||||
|
||||
p('방어 현금형', '보수', SMA_LONG=60, RR_RATIO=1.5, STOP_ATR_MULT=2.5,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=2.5, TURNOVER_BREAKOUT_MULT=2.0,
|
||||
TURNOVER_OVERHEAT_MULT=4.0, FLOW_DAYS=10, ANALYST_MIN_UPSIDE_PCT=12.0,
|
||||
MAX_POSITIONS=4, RISK_PER_TRADE_PCT=0.003, BEAR_ENTRY_MODE=0),
|
||||
p('방어 현금형', '기본', SMA_LONG=60, RR_RATIO=2.0, STOP_ATR_MULT=2.2,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=2.0, TURNOVER_BREAKOUT_MULT=1.8,
|
||||
TURNOVER_OVERHEAT_MULT=4.5, FLOW_DAYS=7, ANALYST_MIN_UPSIDE_PCT=10.0,
|
||||
MAX_POSITIONS=5, RISK_PER_TRADE_PCT=0.005, BEAR_ENTRY_MODE=0),
|
||||
p('방어 현금형', '공격', SMA_LONG=20, RR_RATIO=2.0, STOP_ATR_MULT=2.0,
|
||||
PULLBACK_ATR_MULT=1.0, VOLUME_BREAKOUT_MULT=1.8, TURNOVER_BREAKOUT_MULT=1.5,
|
||||
TURNOVER_OVERHEAT_MULT=5.0, FLOW_DAYS=5, ANALYST_MIN_UPSIDE_PCT=8.0,
|
||||
MAX_POSITIONS=6, RISK_PER_TRADE_PCT=0.0075, BEAR_ENTRY_MODE=1),
|
||||
]
|
||||
|
||||
|
||||
def seed_from_sweep(n: int = 3) -> list[dict]:
|
||||
"""backtest_results.json 검증 상위 n개 → 변이로 등록 (기존 계좌는 유지)."""
|
||||
try:
|
||||
@@ -103,7 +213,7 @@ def sync_label_tops(results: dict) -> dict:
|
||||
'kept': len(current) - removed}
|
||||
|
||||
|
||||
def add_variant(params: dict) -> dict | None:
|
||||
def add_variant(params: dict, name: str | None = None) -> dict | None:
|
||||
"""단일 params 조합을 변이로 추가 (백테스트 행 → 비교군). 이미 있으면 None."""
|
||||
variants = load_variants()
|
||||
seen = {json.dumps(v['params'], sort_keys=True) for v in variants}
|
||||
@@ -111,12 +221,25 @@ def add_variant(params: dict) -> dict | None:
|
||||
return None
|
||||
nums = [int(v['id'][1:]) for v in variants if v['id'][1:].isdigit()]
|
||||
vid = f'v{(max(nums) + 1) if nums else 1}'
|
||||
v = {'id': vid, 'name': _label(params), 'params': params}
|
||||
v = {'id': vid, 'name': name or _label(params), 'params': params}
|
||||
variants.append(v)
|
||||
save_variants(variants)
|
||||
return v
|
||||
|
||||
|
||||
def add_philosophy_presets() -> dict:
|
||||
"""철학별 프리셋 18개를 비교군에 추가. 기존 계좌와 중복 조합은 유지."""
|
||||
added = []
|
||||
skipped = 0
|
||||
for preset in _philosophy_presets():
|
||||
v = add_variant(preset['params'], preset['name'])
|
||||
if v:
|
||||
added.append(v)
|
||||
else:
|
||||
skipped += 1
|
||||
return {'added': added, 'skipped': skipped, 'total': len(load_variants())}
|
||||
|
||||
|
||||
def remove_variant(vid: str):
|
||||
"""변이 1개 삭제 (정의 + 계좌)."""
|
||||
import shutil
|
||||
|
||||
@@ -111,4 +111,5 @@ python3 scripts/watchlist_monitor.py dry-run
|
||||
- Stock code cache: `state/stock_codes.json` (키움 ka10099, lazy 갱신)
|
||||
- Seen 기록: `state/behive_youtube_seen.json`
|
||||
- Fetch 캐시: `state/behive_last_fetch.json`
|
||||
- Cron: `비하이브 종목분석 요약` / `워치리스트 모니터링` — `cron/jobs.json`
|
||||
- OpenClaw cron: 등록명 `비하이브 추천영상 요약` — `state/openclaw.sqlite` 의 `cron_jobs` 테이블 (조회·수정 `openclaw cron {list|show|edit}`)
|
||||
- launchd: 워치리스트 모니터링 = `ai.openclaw.stock.watchlist-monitor` (평일 10·12·14시) — cron 아님
|
||||
|
||||
@@ -32,3 +32,159 @@
|
||||
{"date": "2026-06-12", "market": "KOSPI", "market_label": "코스피", "rise": 756, "upper": 3, "fall": 144, "lower": 0, "steady": 18, "adr": 527.08, "personal": -43174, "foreign": 22041, "institutional": 22870, "captured_at": "2026-06-12T21:00:03.422609+09:00"}
|
||||
{"date": "2026-06-15", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1010, "upper": 10, "fall": 662, "lower": 0, "steady": 62, "adr": 154.08, "personal": 6162, "foreign": -8164, "institutional": 2166, "captured_at": "2026-06-15T21:00:02.416077+09:00"}
|
||||
{"date": "2026-06-15", "market": "KOSPI", "market_label": "코스피", "rise": 676, "upper": 3, "fall": 207, "lower": 0, "steady": 35, "adr": 328.02, "personal": -15124, "foreign": 10774, "institutional": 4462, "captured_at": "2026-06-15T21:00:02.416077+09:00"}
|
||||
{"date": "2026-06-16", "market": "KOSDAQ", "market_label": "코스닥", "rise": 764, "upper": 6, "fall": 896, "lower": 1, "steady": 76, "adr": 85.84, "personal": 7907, "foreign": -3095, "institutional": -4630, "captured_at": "2026-06-16T21:00:02.050758+09:00"}
|
||||
{"date": "2026-06-16", "market": "KOSPI", "market_label": "코스피", "rise": 519, "upper": 5, "fall": 360, "lower": 0, "steady": 38, "adr": 145.56, "personal": -21841, "foreign": 15353, "institutional": 7076, "captured_at": "2026-06-16T21:00:02.050758+09:00"}
|
||||
{"date": "2026-06-17", "market": "KOSDAQ", "market_label": "코스닥", "rise": 957, "upper": 7, "fall": 681, "lower": 0, "steady": 96, "adr": 141.56, "personal": -335, "foreign": 400, "institutional": -101, "captured_at": "2026-06-17T21:00:02.638185+09:00"}
|
||||
{"date": "2026-06-17", "market": "KOSPI", "market_label": "코스피", "rise": 349, "upper": 4, "fall": 526, "lower": 0, "steady": 42, "adr": 67.11, "personal": 5254, "foreign": -9952, "institutional": 5957, "captured_at": "2026-06-17T21:00:02.638185+09:00"}
|
||||
{"date": "2026-06-18", "market": "KOSDAQ", "market_label": "코스닥", "rise": 245, "upper": 12, "fall": 1436, "lower": 0, "steady": 52, "adr": 17.9, "personal": 3842, "foreign": -1235, "institutional": -2659, "captured_at": "2026-06-18T21:00:02.338371+09:00"}
|
||||
{"date": "2026-06-18", "market": "KOSPI", "market_label": "코스피", "rise": 109, "upper": 3, "fall": 791, "lower": 0, "steady": 17, "adr": 14.16, "personal": -4156, "foreign": 12776, "institutional": -7682, "captured_at": "2026-06-18T21:00:02.338371+09:00"}
|
||||
{"date": "2026-06-19", "market": "KOSDAQ", "market_label": "코스닥", "rise": 200, "upper": 7, "fall": 1490, "lower": 1, "steady": 40, "adr": 13.88, "personal": 750, "foreign": 4977, "institutional": -5838, "captured_at": "2026-06-19T21:00:01.989022+09:00"}
|
||||
{"date": "2026-06-19", "market": "KOSPI", "market_label": "코스피", "rise": 115, "upper": 2, "fall": 787, "lower": 0, "steady": 16, "adr": 14.87, "personal": 16478, "foreign": -3555, "institutional": -12244, "captured_at": "2026-06-19T21:00:01.989022+09:00"}
|
||||
{"date": "2026-06-22", "market": "KOSDAQ", "market_label": "코스닥", "rise": 383, "upper": 8, "fall": 1297, "lower": 0, "steady": 53, "adr": 30.15, "personal": -4634, "foreign": 3152, "institutional": 1456, "captured_at": "2026-06-22T21:00:01.907533+09:00"}
|
||||
{"date": "2026-06-22", "market": "KOSPI", "market_label": "코스피", "rise": 148, "upper": 3, "fall": 742, "lower": 1, "steady": 28, "adr": 20.32, "personal": 21506, "foreign": -25466, "institutional": 3034, "captured_at": "2026-06-22T21:00:01.907533+09:00"}
|
||||
{"date": "2026-06-23", "market": "KOSDAQ", "market_label": "코스닥", "rise": 134, "upper": 8, "fall": 1574, "lower": 1, "steady": 26, "adr": 9.02, "personal": -3979, "foreign": 2746, "institutional": 1329, "captured_at": "2026-06-23T21:00:02.466967+09:00"}
|
||||
{"date": "2026-06-23", "market": "KOSPI", "market_label": "코스피", "rise": 46, "upper": 4, "fall": 859, "lower": 0, "steady": 13, "adr": 5.82, "personal": 85910, "foreign": -42047, "institutional": -44760, "captured_at": "2026-06-23T21:00:02.466967+09:00"}
|
||||
{"date": "2026-06-24", "market": "KOSDAQ", "market_label": "코스닥", "rise": 883, "upper": 12, "fall": 786, "lower": 1, "steady": 64, "adr": 113.72, "personal": -3184, "foreign": -321, "institutional": 3363, "captured_at": "2026-06-24T21:00:02.270251+09:00"}
|
||||
{"date": "2026-06-24", "market": "KOSPI", "market_label": "코스피", "rise": 516, "upper": 6, "fall": 367, "lower": 0, "steady": 35, "adr": 142.23, "personal": 26312, "foreign": -46546, "institutional": 19095, "captured_at": "2026-06-24T21:00:02.270251+09:00"}
|
||||
{"date": "2026-06-25", "market": "KOSDAQ", "market_label": "코스닥", "rise": 400, "upper": 8, "fall": 1280, "lower": 4, "steady": 57, "adr": 31.78, "personal": 1452, "foreign": 212, "institutional": -1707, "captured_at": "2026-06-25T21:00:01.814263+09:00"}
|
||||
{"date": "2026-06-25", "market": "KOSPI", "market_label": "코스피", "rise": 291, "upper": 3, "fall": 589, "lower": 3, "steady": 36, "adr": 49.66, "personal": -24136, "foreign": -8754, "institutional": 33244, "captured_at": "2026-06-25T21:00:01.814263+09:00"}
|
||||
{"date": "2026-06-26", "market": "KOSDAQ", "market_label": "코스닥", "rise": 220, "upper": 7, "fall": 1470, "lower": 2, "steady": 50, "adr": 15.42, "personal": -6688, "foreign": 3510, "institutional": 3084, "captured_at": "2026-06-26T21:00:02.514312+09:00"}
|
||||
{"date": "2026-06-26", "market": "KOSPI", "market_label": "코스피", "rise": 111, "upper": 4, "fall": 780, "lower": 2, "steady": 24, "adr": 14.71, "personal": 81919, "foreign": -42906, "institutional": -41224, "captured_at": "2026-06-26T21:00:02.514312+09:00"}
|
||||
{"date": "2026-06-29", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1555, "upper": 14, "fall": 157, "lower": 1, "steady": 30, "adr": 993.04, "personal": -5272, "foreign": 266, "institutional": 5043, "captured_at": "2026-06-29T21:00:03.315108+09:00"}
|
||||
{"date": "2026-06-29", "market": "KOSPI", "market_label": "코스피", "rise": 820, "upper": 6, "fall": 88, "lower": 0, "steady": 9, "adr": 938.64, "personal": 45975, "foreign": -77332, "institutional": 29327, "captured_at": "2026-06-29T21:00:03.315108+09:00"}
|
||||
{"date": "2026-06-30", "market": "KOSDAQ", "market_label": "코스닥", "rise": 718, "upper": 12, "fall": 974, "lower": 1, "steady": 53, "adr": 74.87, "personal": 3909, "foreign": -2464, "institutional": -1428, "captured_at": "2026-06-30T21:00:01.974835+09:00"}
|
||||
{"date": "2026-06-30", "market": "KOSPI", "market_label": "코스피", "rise": 262, "upper": 4, "fall": 623, "lower": 0, "steady": 32, "adr": 42.7, "personal": 8401, "foreign": -37992, "institutional": 29332, "captured_at": "2026-06-30T21:00:01.974835+09:00"}
|
||||
{"date": "2026-07-01", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1193, "upper": 17, "fall": 491, "lower": 1, "steady": 55, "adr": 245.93, "personal": -1082, "foreign": 2472, "institutional": -1255, "captured_at": "2026-07-01T21:00:01.866864+09:00"}
|
||||
{"date": "2026-07-01", "market": "KOSPI", "market_label": "코스피", "rise": 700, "upper": 11, "fall": 193, "lower": 1, "steady": 25, "adr": 366.49, "personal": 17370, "foreign": -17028, "institutional": -700, "captured_at": "2026-07-01T21:00:01.866864+09:00"}
|
||||
{"date": "2026-07-02", "market": "KOSDAQ", "market_label": "코스닥", "rise": 318, "upper": 8, "fall": 1355, "lower": 3, "steady": 66, "adr": 24.01, "personal": 4859, "foreign": -1942, "institutional": -3091, "captured_at": "2026-07-02T21:00:05.425991+09:00"}
|
||||
{"date": "2026-07-02", "market": "KOSPI", "market_label": "코스피", "rise": 280, "upper": 4, "fall": 616, "lower": 1, "steady": 20, "adr": 46.03, "personal": 62413, "foreign": -43706, "institutional": -20825, "captured_at": "2026-07-02T21:00:05.425991+09:00"}
|
||||
{"date": "2026-07-03", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1094, "upper": 8, "fall": 566, "lower": 0, "steady": 78, "adr": 194.7, "personal": 1075, "foreign": -202, "institutional": -1037, "captured_at": "2026-07-03T21:00:02.137974+09:00"}
|
||||
{"date": "2026-07-03", "market": "KOSPI", "market_label": "코스피", "rise": 589, "upper": 2, "fall": 297, "lower": 1, "steady": 27, "adr": 198.32, "personal": -22942, "foreign": -21750, "institutional": 44079, "captured_at": "2026-07-03T21:00:02.137974+09:00"}
|
||||
{"date": "2026-07-06", "market": "KOSDAQ", "market_label": "코스닥", "rise": 683, "upper": 6, "fall": 982, "lower": 0, "steady": 74, "adr": 70.16, "personal": 2591, "foreign": -447, "institutional": -2244, "captured_at": "2026-07-06T21:00:03.092182+09:00"}
|
||||
{"date": "2026-07-06", "market": "KOSPI", "market_label": "코스피", "rise": 464, "upper": 8, "fall": 412, "lower": 0, "steady": 37, "adr": 114.56, "personal": 26829, "foreign": -13144, "institutional": -14618, "captured_at": "2026-07-06T21:00:03.092182+09:00"}
|
||||
{"date": "2026-07-07", "market": "KOSDAQ", "market_label": "코스닥", "rise": 484, "upper": 6, "fall": 1194, "lower": 1, "steady": 61, "adr": 41.0, "personal": -3616, "foreign": 3742, "institutional": -100, "captured_at": "2026-07-07T21:00:04.611682+09:00"}
|
||||
{"date": "2026-07-07", "market": "KOSPI", "market_label": "코스피", "rise": 358, "upper": 1, "fall": 512, "lower": 0, "steady": 42, "adr": 70.12, "personal": 31359, "foreign": -29172, "institutional": -3108, "captured_at": "2026-07-07T21:00:04.611682+09:00"}
|
||||
{"date": "2026-07-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 246, "upper": 4, "fall": 1445, "lower": 1, "steady": 48, "adr": 17.29, "personal": -1959, "foreign": 3376, "institutional": -1453, "captured_at": "2026-07-08T21:00:03.487551+09:00"}
|
||||
{"date": "2026-07-08", "market": "KOSPI", "market_label": "코스피", "rise": 125, "upper": 3, "fall": 765, "lower": 0, "steady": 22, "adr": 16.73, "personal": -358, "foreign": 3437, "institutional": -3552, "captured_at": "2026-07-08T21:00:03.487551+09:00"}
|
||||
{"date": "2026-07-09", "market": "KOSDAQ", "market_label": "코스닥", "rise": 775, "upper": 8, "fall": 887, "lower": 0, "steady": 77, "adr": 88.28, "personal": -3217, "foreign": 221, "institutional": 3081, "captured_at": "2026-07-09T21:00:02.779248+09:00"}
|
||||
{"date": "2026-07-09", "market": "KOSPI", "market_label": "코스피", "rise": 237, "upper": 1, "fall": 652, "lower": 0, "steady": 24, "adr": 36.5, "personal": -13278, "foreign": 1343, "institutional": 12885, "captured_at": "2026-07-09T21:00:02.779248+09:00"}
|
||||
{"date": "2026-07-10", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1488, "upper": 4, "fall": 204, "lower": 5, "steady": 43, "adr": 713.88, "personal": -4213, "foreign": -1563, "institutional": 5825, "captured_at": "2026-07-10T21:00:02.641644+09:00"}
|
||||
{"date": "2026-07-10", "market": "KOSPI", "market_label": "코스피", "rise": 802, "upper": 6, "fall": 92, "lower": 0, "steady": 19, "adr": 878.26, "personal": -7805, "foreign": -3228, "institutional": 11314, "captured_at": "2026-07-10T21:00:02.641644+09:00"}
|
||||
{"date": "2026-07-13", "market": "KOSDAQ", "market_label": "코스닥", "rise": 221, "upper": 4, "fall": 1460, "lower": 2, "steady": 57, "adr": 15.39, "personal": 1833, "foreign": -3878, "institutional": 1843, "captured_at": "2026-07-13T21:00:01.940921+09:00"}
|
||||
{"date": "2026-07-13", "market": "KOSPI", "market_label": "코스피", "rise": 179, "upper": 3, "fall": 716, "lower": 0, "steady": 18, "adr": 25.42, "personal": 38869, "foreign": -16705, "institutional": -22338, "captured_at": "2026-07-13T21:00:01.940921+09:00"}
|
||||
{"date": "2026-07-14", "market": "KOSDAQ", "market_label": "코스닥", "rise": 445, "upper": 6, "fall": 1212, "lower": 0, "steady": 83, "adr": 37.21, "personal": 698, "foreign": -2471, "institutional": 1587, "captured_at": "2026-07-14T21:00:05.513305+09:00"}
|
||||
{"date": "2026-07-14", "market": "KOSPI", "market_label": "코스피", "rise": 219, "upper": 5, "fall": 662, "lower": 0, "steady": 31, "adr": 33.84, "personal": -41411, "foreign": 9565, "institutional": 32117, "captured_at": "2026-07-14T21:00:05.513305+09:00"}
|
||||
{"date": "2026-07-15", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1465, "upper": 14, "fall": 217, "lower": 1, "steady": 57, "adr": 678.44, "personal": -1542, "foreign": 231, "institutional": 1086, "captured_at": "2026-07-15T21:00:04.202161+09:00"}
|
||||
{"date": "2026-07-15", "market": "KOSPI", "market_label": "코스피", "rise": 711, "upper": 7, "fall": 169, "lower": 0, "steady": 32, "adr": 424.85, "personal": -24666, "foreign": 23031, "institutional": 1992, "captured_at": "2026-07-15T21:00:04.202161+09:00"}
|
||||
{"date": "2026-07-16", "market": "KOSDAQ", "market_label": "코스닥", "rise": 501, "upper": 11, "fall": 1182, "lower": 1, "steady": 56, "adr": 43.28, "personal": 4184, "foreign": -2919, "institutional": -1553, "captured_at": "2026-07-16T21:00:01.884912+09:00"}
|
||||
{"date": "2026-07-16", "market": "KOSPI", "market_label": "코스피", "rise": 384, "upper": 6, "fall": 488, "lower": 0, "steady": 40, "adr": 79.92, "personal": 36647, "foreign": -13665, "institutional": -23831, "captured_at": "2026-07-16T21:00:01.884912+09:00"}
|
||||
{"date": "2026-07-20", "market": "KOSDAQ", "market_label": "코스닥", "rise": 198, "upper": 10, "fall": 1487, "lower": 3, "steady": 54, "adr": 13.96, "personal": 1907, "foreign": -518, "institutional": -1330, "captured_at": "2026-07-20T21:00:02.383101+09:00"}
|
||||
{"date": "2026-07-20", "market": "KOSPI", "market_label": "코스피", "rise": 97, "upper": 3, "fall": 799, "lower": 4, "steady": 18, "adr": 12.45, "personal": 3490, "foreign": 5198, "institutional": -9235, "captured_at": "2026-07-20T21:00:02.383101+09:00"}
|
||||
{"date": "2026-07-21", "market": "KOSDAQ", "market_label": "코스닥", "rise": 983, "upper": 19, "fall": 657, "lower": 5, "steady": 96, "adr": 151.36, "personal": 1474, "foreign": -1360, "institutional": -129, "captured_at": "2026-07-21T21:00:02.218278+09:00"}
|
||||
{"date": "2026-07-21", "market": "KOSPI", "market_label": "코스피", "rise": 485, "upper": 1, "fall": 382, "lower": 2, "steady": 47, "adr": 126.56, "personal": -16421, "foreign": 2952, "institutional": 13744, "captured_at": "2026-07-21T21:00:02.218278+09:00"}
|
||||
{"date": "2026-07-22", "market": "KOSDAQ", "market_label": "코스닥", "rise": 839, "upper": 14, "fall": 793, "lower": 3, "steady": 103, "adr": 107.16, "personal": 2636, "foreign": -815, "institutional": -1914, "captured_at": "2026-07-22T21:00:02.077211+09:00"}
|
||||
{"date": "2026-07-22", "market": "KOSPI", "market_label": "코스피", "rise": 469, "upper": 0, "fall": 399, "lower": 0, "steady": 47, "adr": 117.54, "personal": -12176, "foreign": 26211, "institutional": -13963, "captured_at": "2026-07-22T21:00:02.077211+09:00"}
|
||||
{"date": "2026-07-23", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1442, "upper": 9, "fall": 252, "lower": 1, "steady": 42, "adr": 573.52, "personal": -2373, "foreign": 1675, "institutional": 643, "captured_at": "2026-07-23T21:00:03.478785+09:00"}
|
||||
{"date": "2026-07-23", "market": "KOSPI", "market_label": "코스피", "rise": 831, "upper": 4, "fall": 74, "lower": 0, "steady": 10, "adr": 1128.38, "personal": -22077, "foreign": 21359, "institutional": 976, "captured_at": "2026-07-23T21:00:03.478785+09:00"}
|
||||
{"date": "2026-07-24", "market": "KOSDAQ", "market_label": "코스닥", "rise": 371, "upper": 9, "fall": 1281, "lower": 1, "steady": 80, "adr": 29.64, "personal": 4881, "foreign": -1315, "institutional": -3580, "captured_at": "2026-07-24T21:00:02.378107+09:00"}
|
||||
{"date": "2026-07-24", "market": "KOSPI", "market_label": "코스피", "rise": 301, "upper": 2, "fall": 585, "lower": 0, "steady": 30, "adr": 51.79, "personal": 51782, "foreign": -32683, "institutional": -19514, "captured_at": "2026-07-24T21:00:02.378107+09:00"}
|
||||
{"date": "2026-07-27", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1007, "upper": 10, "fall": 628, "lower": 2, "steady": 99, "adr": 161.43, "personal": -1550, "foreign": -1422, "institutional": 2731, "captured_at": "2026-07-27T21:00:02.567797+09:00"}
|
||||
{"date": "2026-07-27", "market": "KOSPI", "market_label": "코스피", "rise": 497, "upper": 0, "fall": 372, "lower": 2, "steady": 47, "adr": 132.89, "personal": 19788, "foreign": -28811, "institutional": 8595, "captured_at": "2026-07-27T21:00:02.567797+09:00"}
|
||||
{"date": "2026-07-28", "market": "KOSDAQ", "market_label": "코스닥", "rise": 140, "upper": 11, "fall": 1557, "lower": 2, "steady": 38, "adr": 9.69, "personal": 480, "foreign": 860, "institutional": -1345, "captured_at": "2026-07-28T21:00:02.094342+09:00"}
|
||||
{"date": "2026-07-28", "market": "KOSPI", "market_label": "코스피", "rise": 36, "upper": 3, "fall": 878, "lower": 2, "steady": 3, "adr": 4.43, "personal": 43152, "foreign": -45009, "institutional": 1830, "captured_at": "2026-07-28T21:00:02.094342+09:00"}
|
||||
{"date": "2026-07-29", "market": "KOSDAQ", "market_label": "코스닥", "rise": 141, "upper": 4, "fall": 1554, "lower": 3, "steady": 35, "adr": 9.31, "personal": -4579, "foreign": 3082, "institutional": 1474, "captured_at": "2026-07-29T21:00:04.833116+09:00"}
|
||||
{"date": "2026-07-29", "market": "KOSPI", "market_label": "코스피", "rise": 91, "upper": 1, "fall": 805, "lower": 1, "steady": 21, "adr": 11.41, "personal": -19701, "foreign": -12502, "institutional": 31769, "captured_at": "2026-07-29T21:00:04.833116+09:00"}
|
||||
{"date": "2026-07-30", "market": "KOSDAQ", "market_label": "코스닥", "rise": 733, "upper": 5, "fall": 938, "lower": 0, "steady": 57, "adr": 78.68, "personal": -1108, "foreign": 2483, "institutional": -1435, "captured_at": "2026-07-30T21:00:02.501493+09:00"}
|
||||
{"date": "2026-07-30", "market": "KOSPI", "market_label": "코스피", "rise": 609, "upper": 4, "fall": 277, "lower": 0, "steady": 30, "adr": 221.3, "personal": -14309, "foreign": 13280, "institutional": 805, "captured_at": "2026-07-30T21:00:02.501493+09:00"}
|
||||
{"date": "2026-07-31", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1557, "upper": 14, "fall": 141, "lower": 0, "steady": 30, "adr": 1114.18, "personal": -4715, "foreign": 1697, "institutional": 2985, "captured_at": "2026-07-31T21:00:02.143730+09:00"}
|
||||
{"date": "2026-07-31", "market": "KOSPI", "market_label": "코스피", "rise": 775, "upper": 12, "fall": 127, "lower": 0, "steady": 15, "adr": 619.69, "personal": -82840, "foreign": 72410, "institutional": 11503, "captured_at": "2026-07-31T21:00:02.143730+09:00"}
|
||||
{"date": "2026-08-03", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1170, "upper": 11, "fall": 468, "lower": 0, "steady": 89, "adr": 252.35, "personal": 302, "foreign": -2092, "institutional": 1669, "captured_at": "2026-08-03T21:00:05.036019+09:00"}
|
||||
{"date": "2026-08-03", "market": "KOSPI", "market_label": "코스피", "rise": 455, "upper": 3, "fall": 419, "lower": 0, "steady": 43, "adr": 109.31, "personal": 46523, "foreign": -28220, "institutional": -19516, "captured_at": "2026-08-03T21:00:05.036019+09:00"}
|
||||
{"date": "2026-08-04", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1505, "upper": 11, "fall": 175, "lower": 0, "steady": 47, "adr": 866.29, "personal": -2587, "foreign": -2775, "institutional": 5438, "captured_at": "2026-08-04T21:00:01.937958+09:00"}
|
||||
{"date": "2026-08-04", "market": "KOSPI", "market_label": "코스피", "rise": 788, "upper": 1, "fall": 96, "lower": 0, "steady": 31, "adr": 821.88, "personal": 8187, "foreign": -3716, "institutional": -5385, "captured_at": "2026-08-04T21:00:01.937958+09:00"}
|
||||
{"date": "2026-08-05", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1346, "upper": 11, "fall": 301, "lower": 0, "steady": 78, "adr": 450.83, "personal": 3941, "foreign": -2979, "institutional": -1101, "captured_at": "2026-08-05T21:00:05.552836+09:00"}
|
||||
{"date": "2026-08-05", "market": "KOSPI", "market_label": "코스피", "rise": 675, "upper": 0, "fall": 197, "lower": 0, "steady": 43, "adr": 342.64, "personal": -11844, "foreign": 14464, "institutional": -2838, "captured_at": "2026-08-05T21:00:05.552836+09:00"}
|
||||
{"date": "2026-08-06", "market": "KOSDAQ", "market_label": "코스닥", "rise": 736, "upper": 7, "fall": 893, "lower": 0, "steady": 95, "adr": 83.2, "personal": 863, "foreign": -2481, "institutional": 1615, "captured_at": "2026-08-06T21:00:02.122175+09:00"}
|
||||
{"date": "2026-08-06", "market": "KOSPI", "market_label": "코스피", "rise": 490, "upper": 3, "fall": 381, "lower": 0, "steady": 44, "adr": 129.4, "personal": 33367, "foreign": -32893, "institutional": -1214, "captured_at": "2026-08-06T21:00:02.122175+09:00"}
|
||||
{"date": "2026-08-07", "market": "KOSDAQ", "market_label": "코스닥", "rise": 808, "upper": 6, "fall": 829, "lower": 0, "steady": 84, "adr": 98.19, "personal": 3402, "foreign": -2522, "institutional": -1044, "captured_at": "2026-08-07T21:00:06.627011+09:00"}
|
||||
{"date": "2026-08-07", "market": "KOSPI", "market_label": "코스피", "rise": 554, "upper": 3, "fall": 322, "lower": 0, "steady": 36, "adr": 172.98, "personal": 2675, "foreign": -8651, "institutional": 5854, "captured_at": "2026-08-07T21:00:06.627011+09:00"}
|
||||
{"date": "2026-08-10", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1431, "upper": 11, "fall": 236, "lower": 0, "steady": 52, "adr": 611.02, "personal": -6729, "foreign": 1027, "institutional": 5667, "captured_at": "2026-08-10T21:00:01.810609+09:00"}
|
||||
{"date": "2026-08-10", "market": "KOSPI", "market_label": "코스피", "rise": 697, "upper": 0, "fall": 183, "lower": 0, "steady": 31, "adr": 380.87, "personal": 9003, "foreign": -14909, "institutional": 5692, "captured_at": "2026-08-10T21:00:01.810609+09:00"}
|
||||
{"date": "2026-08-11", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1097, "upper": 8, "fall": 547, "lower": 0, "steady": 76, "adr": 202.01, "personal": 4351, "foreign": -3562, "institutional": -954, "captured_at": "2026-08-11T21:00:02.471962+09:00"}
|
||||
{"date": "2026-08-11", "market": "KOSPI", "market_label": "코스피", "rise": 563, "upper": 4, "fall": 309, "lower": 0, "steady": 38, "adr": 183.5, "personal": -708, "foreign": 535, "institutional": 212, "captured_at": "2026-08-11T21:00:02.471962+09:00"}
|
||||
{"date": "2026-08-12", "market": "KOSDAQ", "market_label": "코스닥", "rise": 886, "upper": 8, "fall": 753, "lower": 0, "steady": 83, "adr": 118.73, "personal": 3127, "foreign": -1623, "institutional": -1459, "captured_at": "2026-08-12T21:00:02.259243+09:00"}
|
||||
{"date": "2026-08-12", "market": "KOSPI", "market_label": "코스피", "rise": 380, "upper": 3, "fall": 477, "lower": 0, "steady": 53, "adr": 80.29, "personal": -31911, "foreign": 28354, "institutional": 5279, "captured_at": "2026-08-12T21:00:02.259243+09:00"}
|
||||
{"date": "2026-08-13", "market": "KOSDAQ", "market_label": "코스닥", "rise": 634, "upper": 9, "fall": 1009, "lower": 1, "steady": 81, "adr": 63.66, "personal": 2565, "foreign": -1362, "institutional": -1132, "captured_at": "2026-08-13T21:00:04.775938+09:00"}
|
||||
{"date": "2026-08-13", "market": "KOSPI", "market_label": "코스피", "rise": 333, "upper": 1, "fall": 533, "lower": 0, "steady": 35, "adr": 62.66, "personal": -27410, "foreign": 21102, "institutional": 6834, "captured_at": "2026-08-13T21:00:04.775938+09:00"}
|
||||
{"date": "2026-08-14", "market": "KOSDAQ", "market_label": "코스닥", "rise": 904, "upper": 9, "fall": 741, "lower": 0, "steady": 79, "adr": 123.21, "personal": 3145, "foreign": -659, "institutional": -2524, "captured_at": "2026-08-14T21:00:02.471174+09:00"}
|
||||
{"date": "2026-08-14", "market": "KOSPI", "market_label": "코스피", "rise": 677, "upper": 3, "fall": 204, "lower": 0, "steady": 25, "adr": 333.33, "personal": -19820, "foreign": 30387, "institutional": -10298, "captured_at": "2026-08-14T21:00:02.471174+09:00"}
|
||||
{"date": "2026-08-18", "market": "KOSDAQ", "market_label": "코스닥", "rise": 372, "upper": 8, "fall": 1288, "lower": 1, "steady": 68, "adr": 29.48, "personal": 3905, "foreign": 366, "institutional": -4176, "captured_at": "2026-08-18T21:00:02.165948+09:00"}
|
||||
{"date": "2026-08-18", "market": "KOSPI", "market_label": "코스피", "rise": 195, "upper": 5, "fall": 682, "lower": 0, "steady": 29, "adr": 29.33, "personal": 7420, "foreign": 914, "institutional": -7951, "captured_at": "2026-08-18T21:00:02.165948+09:00"}
|
||||
{"date": "2026-08-19", "market": "KOSDAQ", "market_label": "코스닥", "rise": 396, "upper": 6, "fall": 1275, "lower": 3, "steady": 58, "adr": 31.46, "personal": -664, "foreign": 147, "institutional": 500, "captured_at": "2026-08-19T21:00:06.595526+09:00"}
|
||||
{"date": "2026-08-19", "market": "KOSPI", "market_label": "코스피", "rise": 171, "upper": 3, "fall": 703, "lower": 0, "steady": 32, "adr": 24.75, "personal": 46357, "foreign": -34726, "institutional": -13390, "captured_at": "2026-08-19T21:00:06.595526+09:00"}
|
||||
{"date": "2026-08-20", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1064, "upper": 5, "fall": 576, "lower": 1, "steady": 86, "adr": 185.27, "personal": 16, "foreign": -1328, "institutional": 1194, "captured_at": "2026-08-20T21:00:05.687686+09:00"}
|
||||
{"date": "2026-08-20", "market": "KOSPI", "market_label": "코스피", "rise": 521, "upper": 4, "fall": 338, "lower": 0, "steady": 46, "adr": 155.33, "personal": -22712, "foreign": 17068, "institutional": -4895, "captured_at": "2026-08-20T21:00:05.687686+09:00"}
|
||||
{"date": "2026-08-21", "market": "KOSDAQ", "market_label": "코스닥", "rise": 282, "upper": 5, "fall": 1378, "lower": 3, "steady": 63, "adr": 20.78, "personal": 6236, "foreign": -2837, "institutional": -3462, "captured_at": "2026-08-21T21:00:04.974633+09:00"}
|
||||
{"date": "2026-08-21", "market": "KOSPI", "market_label": "코스피", "rise": 193, "upper": 0, "fall": 683, "lower": 0, "steady": 28, "adr": 28.26, "personal": -11652, "foreign": -1760, "institutional": 2481, "captured_at": "2026-08-21T21:00:04.974633+09:00"}
|
||||
{"date": "2026-08-24", "market": "KOSDAQ", "market_label": "코스닥", "rise": 921, "upper": 5, "fall": 706, "lower": 2, "steady": 96, "adr": 130.79, "personal": -2604, "foreign": 2416, "institutional": 249, "captured_at": "2026-08-24T21:00:02.141569+09:00"}
|
||||
{"date": "2026-08-24", "market": "KOSPI", "market_label": "코스피", "rise": 579, "upper": 3, "fall": 286, "lower": 0, "steady": 38, "adr": 203.5, "personal": 33217, "foreign": -36691, "institutional": -12901, "captured_at": "2026-08-24T21:00:02.141569+09:00"}
|
||||
{"date": "2026-08-25", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1186, "upper": 4, "fall": 466, "lower": 0, "steady": 74, "adr": 255.36, "personal": -1375, "foreign": 1327, "institutional": 156, "captured_at": "2026-08-25T21:00:03.869052+09:00"}
|
||||
{"date": "2026-08-25", "market": "KOSPI", "market_label": "코스피", "rise": 647, "upper": 2, "fall": 226, "lower": 1, "steady": 34, "adr": 285.9, "personal": 10509, "foreign": -38140, "institutional": 11711, "captured_at": "2026-08-25T21:00:03.869052+09:00"}
|
||||
{"date": "2026-08-26", "market": "KOSDAQ", "market_label": "코스닥", "rise": 907, "upper": 8, "fall": 708, "lower": 0, "steady": 111, "adr": 129.24, "personal": 1931, "foreign": -918, "institutional": -1054, "captured_at": "2026-08-26T21:00:04.234334+09:00"}
|
||||
{"date": "2026-08-26", "market": "KOSPI", "market_label": "코스피", "rise": 585, "upper": 2, "fall": 275, "lower": 0, "steady": 47, "adr": 213.45, "personal": -22461, "foreign": -1148, "institutional": 7615, "captured_at": "2026-08-26T21:00:04.234334+09:00"}
|
||||
{"date": "2026-08-27", "market": "KOSDAQ", "market_label": "코스닥", "rise": 840, "upper": 7, "fall": 794, "lower": 0, "steady": 96, "adr": 106.68, "personal": -1752, "foreign": 2955, "institutional": -1192, "captured_at": "2026-08-27T21:00:03.016599+09:00"}
|
||||
{"date": "2026-08-27", "market": "KOSPI", "market_label": "코스피", "rise": 317, "upper": 0, "fall": 539, "lower": 0, "steady": 50, "adr": 58.81, "personal": -19116, "foreign": 1333, "institutional": 1835, "captured_at": "2026-08-27T21:00:03.016599+09:00"}
|
||||
{"date": "2026-08-28", "market": "KOSDAQ", "market_label": "코스닥", "rise": 957, "upper": 6, "fall": 690, "lower": 0, "steady": 83, "adr": 139.57, "personal": 979, "foreign": -942, "institutional": -30, "captured_at": "2026-08-28T21:00:04.731356+09:00"}
|
||||
{"date": "2026-08-28", "market": "KOSPI", "market_label": "코스피", "rise": 640, "upper": 2, "fall": 238, "lower": 0, "steady": 32, "adr": 269.75, "personal": 4194, "foreign": -8525, "institutional": -11876, "captured_at": "2026-08-28T21:00:04.731356+09:00"}
|
||||
{"date": "2026-08-31", "market": "KOSDAQ", "market_label": "코스닥", "rise": 741, "upper": 8, "fall": 889, "lower": 0, "steady": 105, "adr": 84.25, "personal": 2530, "foreign": -552, "institutional": -2005, "captured_at": "2026-08-31T21:00:05.779900+09:00"}
|
||||
{"date": "2026-08-31", "market": "KOSPI", "market_label": "코스피", "rise": 366, "upper": 0, "fall": 489, "lower": 1, "steady": 54, "adr": 74.69, "personal": -1464, "foreign": -6434, "institutional": -7891, "captured_at": "2026-08-31T21:00:05.779900+09:00"}
|
||||
{"date": "2026-09-01", "market": "KOSDAQ", "market_label": "코스닥", "rise": 686, "upper": 3, "fall": 950, "lower": 1, "steady": 98, "adr": 72.45, "personal": 4389, "foreign": -1531, "institutional": -2787, "captured_at": "2026-09-01T21:00:02.657470+09:00"}
|
||||
{"date": "2026-09-01", "market": "KOSPI", "market_label": "코스피", "rise": 446, "upper": 4, "fall": 422, "lower": 0, "steady": 40, "adr": 106.64, "personal": -5402, "foreign": -4968, "institutional": -6286, "captured_at": "2026-09-01T21:00:02.657470+09:00"}
|
||||
{"date": "2026-09-02", "market": "KOSDAQ", "market_label": "코스닥", "rise": 359, "upper": 7, "fall": 1298, "lower": 4, "steady": 76, "adr": 28.11, "personal": 1727, "foreign": 618, "institutional": -2322, "captured_at": "2026-09-02T21:00:04.146664+09:00"}
|
||||
{"date": "2026-09-02", "market": "KOSPI", "market_label": "코스피", "rise": 139, "upper": 1, "fall": 735, "lower": 0, "steady": 37, "adr": 19.05, "personal": 23029, "foreign": -19173, "institutional": -20361, "captured_at": "2026-09-02T21:00:04.146664+09:00"}
|
||||
{"date": "2026-09-03", "market": "KOSDAQ", "market_label": "코스닥", "rise": 530, "upper": 3, "fall": 1108, "lower": 1, "steady": 93, "adr": 48.06, "personal": 2950, "foreign": -1082, "institutional": -1838, "captured_at": "2026-09-03T21:00:03.846475+09:00"}
|
||||
{"date": "2026-09-03", "market": "KOSPI", "market_label": "코스피", "rise": 424, "upper": 0, "fall": 433, "lower": 0, "steady": 52, "adr": 97.92, "personal": -9552, "foreign": 414, "institutional": 9143, "captured_at": "2026-09-03T21:00:03.846475+09:00"}
|
||||
{"date": "2026-09-04", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1199, "upper": 4, "fall": 436, "lower": 1, "steady": 97, "adr": 275.29, "personal": -4125, "foreign": 2599, "institutional": 1609, "captured_at": "2026-09-04T21:00:05.467425+09:00"}
|
||||
{"date": "2026-09-04", "market": "KOSPI", "market_label": "코스피", "rise": 549, "upper": 1, "fall": 309, "lower": 0, "steady": 51, "adr": 177.99, "personal": -37229, "foreign": 4796, "institutional": 16694, "captured_at": "2026-09-04T21:00:05.467425+09:00"}
|
||||
{"date": "2026-09-07", "market": "KOSDAQ", "market_label": "코스닥", "rise": 833, "upper": 9, "fall": 807, "lower": 1, "steady": 95, "adr": 104.21, "personal": 1896, "foreign": -717, "institutional": -1292, "captured_at": "2026-09-07T21:00:02.700353+09:00"}
|
||||
{"date": "2026-09-07", "market": "KOSPI", "market_label": "코스피", "rise": 478, "upper": 1, "fall": 381, "lower": 0, "steady": 52, "adr": 125.72, "personal": -68374, "foreign": 25532, "institutional": 26491, "captured_at": "2026-09-07T21:00:02.700353+09:00"}
|
||||
{"date": "2026-09-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 506, "upper": 6, "fall": 1141, "lower": 0, "steady": 90, "adr": 44.87, "personal": 479, "foreign": 1583, "institutional": -2135, "captured_at": "2026-09-08T21:00:05.322694+09:00"}
|
||||
{"date": "2026-09-08", "market": "KOSPI", "market_label": "코스피", "rise": 231, "upper": 0, "fall": 634, "lower": 0, "steady": 45, "adr": 36.44, "personal": -30329, "foreign": 6316, "institutional": 6427, "captured_at": "2026-09-08T21:00:05.322694+09:00"}
|
||||
{"date": "2026-09-09", "market": "KOSDAQ", "market_label": "코스닥", "rise": 962, "upper": 6, "fall": 672, "lower": 0, "steady": 99, "adr": 144.05, "personal": -2548, "foreign": 2646, "institutional": -190, "captured_at": "2026-09-09T21:00:02.199305+09:00"}
|
||||
{"date": "2026-09-09", "market": "KOSPI", "market_label": "코스피", "rise": 463, "upper": 0, "fall": 400, "lower": 0, "steady": 50, "adr": 115.75, "personal": -22879, "foreign": -4348, "institutional": 9422, "captured_at": "2026-09-09T21:00:02.199305+09:00"}
|
||||
{"date": "2026-09-10", "market": "KOSDAQ", "market_label": "코스닥", "rise": 827, "upper": 3, "fall": 796, "lower": 1, "steady": 115, "adr": 104.14, "personal": -2698, "foreign": -11253, "institutional": 13996, "captured_at": "2026-09-10T21:00:02.740511+09:00"}
|
||||
{"date": "2026-09-10", "market": "KOSPI", "market_label": "코스피", "rise": 419, "upper": 3, "fall": 443, "lower": 0, "steady": 53, "adr": 95.26, "personal": 3802, "foreign": -26217, "institutional": 5744, "captured_at": "2026-09-10T21:00:02.740511+09:00"}
|
||||
{"date": "2026-09-11", "market": "KOSDAQ", "market_label": "코스닥", "rise": 607, "upper": 11, "fall": 1004, "lower": 0, "steady": 124, "adr": 61.55, "personal": 5120, "foreign": -3123, "institutional": -2159, "captured_at": "2026-09-11T21:00:06.064390+09:00"}
|
||||
{"date": "2026-09-11", "market": "KOSPI", "market_label": "코스피", "rise": 369, "upper": 1, "fall": 475, "lower": 0, "steady": 73, "adr": 77.89, "personal": 18675, "foreign": -22984, "institutional": -12184, "captured_at": "2026-09-11T21:00:06.064390+09:00"}
|
||||
{"date": "2026-09-14", "market": "KOSDAQ", "market_label": "코스닥", "rise": 545, "upper": 6, "fall": 1115, "lower": 0, "steady": 77, "adr": 49.42, "personal": 1029, "foreign": -923, "institutional": -238, "captured_at": "2026-09-14T21:00:06.345847+09:00"}
|
||||
{"date": "2026-09-14", "market": "KOSPI", "market_label": "코스피", "rise": 309, "upper": 2, "fall": 563, "lower": 0, "steady": 45, "adr": 55.24, "personal": 30351, "foreign": -33363, "institutional": -11869, "captured_at": "2026-09-14T21:00:06.345847+09:00"}
|
||||
{"date": "2026-09-15", "market": "KOSDAQ", "market_label": "코스닥", "rise": 788, "upper": 8, "fall": 842, "lower": 0, "steady": 101, "adr": 94.54, "personal": -1478, "foreign": 272, "institutional": 1149, "captured_at": "2026-09-15T21:00:06.045245+09:00"}
|
||||
{"date": "2026-09-15", "market": "KOSPI", "market_label": "코스피", "rise": 254, "upper": 1, "fall": 623, "lower": 0, "steady": 40, "adr": 40.93, "personal": 8596, "foreign": -15458, "institutional": -9585, "captured_at": "2026-09-15T21:00:06.045245+09:00"}
|
||||
{"date": "2026-09-16", "market": "KOSDAQ", "market_label": "코스닥", "rise": 739, "upper": 7, "fall": 893, "lower": 0, "steady": 99, "adr": 83.54, "personal": 62, "foreign": -272, "institutional": 222, "captured_at": "2026-09-16T21:00:05.119909+09:00"}
|
||||
{"date": "2026-09-16", "market": "KOSPI", "market_label": "코스피", "rise": 304, "upper": 0, "fall": 578, "lower": 1, "steady": 35, "adr": 52.5, "personal": -12061, "foreign": -16726, "institutional": 12251, "captured_at": "2026-09-16T21:00:05.119909+09:00"}
|
||||
{"date": "2026-09-17", "market": "KOSDAQ", "market_label": "코스닥", "rise": 945, "upper": 6, "fall": 662, "lower": 0, "steady": 123, "adr": 143.66, "personal": -250, "foreign": -329, "institutional": 455, "captured_at": "2026-09-17T21:00:06.412999+09:00"}
|
||||
{"date": "2026-09-17", "market": "KOSPI", "market_label": "코스피", "rise": 453, "upper": 1, "fall": 398, "lower": 0, "steady": 62, "adr": 114.07, "personal": 3020, "foreign": -22546, "institutional": 2480, "captured_at": "2026-09-17T21:00:06.412999+09:00"}
|
||||
{"date": "2026-09-18", "market": "KOSDAQ", "market_label": "코스닥", "rise": 919, "upper": 3, "fall": 698, "lower": 2, "steady": 116, "adr": 131.71, "personal": 162, "foreign": -511, "institutional": 250, "captured_at": "2026-09-18T21:00:02.289980+09:00"}
|
||||
{"date": "2026-09-18", "market": "KOSPI", "market_label": "코스피", "rise": 407, "upper": 5, "fall": 465, "lower": 1, "steady": 41, "adr": 88.41, "personal": -36359, "foreign": 4486, "institutional": 15322, "captured_at": "2026-09-18T21:00:02.289980+09:00"}
|
||||
{"date": "2026-09-21", "market": "KOSDAQ", "market_label": "코스닥", "rise": 628, "upper": 5, "fall": 1023, "lower": 0, "steady": 82, "adr": 61.88, "personal": 1416, "foreign": -995, "institutional": -285, "captured_at": "2026-09-21T21:00:05.395370+09:00"}
|
||||
{"date": "2026-09-21", "market": "KOSPI", "market_label": "코스피", "rise": 310, "upper": 0, "fall": 552, "lower": 0, "steady": 52, "adr": 56.16, "personal": -31850, "foreign": 117, "institutional": 15162, "captured_at": "2026-09-21T21:00:05.395370+09:00"}
|
||||
{"date": "2026-09-22", "market": "KOSDAQ", "market_label": "코스닥", "rise": 625, "upper": 6, "fall": 1002, "lower": 2, "steady": 107, "adr": 62.85, "personal": 1548, "foreign": -545, "institutional": -953, "captured_at": "2026-09-22T21:00:06.024212+09:00"}
|
||||
{"date": "2026-09-22", "market": "KOSPI", "market_label": "코스피", "rise": 383, "upper": 0, "fall": 463, "lower": 0, "steady": 68, "adr": 82.72, "personal": -15643, "foreign": 494, "institutional": -1295, "captured_at": "2026-09-22T21:00:06.024212+09:00"}
|
||||
{"date": "2026-09-23", "market": "KOSDAQ", "market_label": "코스닥", "rise": 856, "upper": 4, "fall": 786, "lower": 0, "steady": 94, "adr": 109.41, "personal": 275, "foreign": -462, "institutional": 76, "captured_at": "2026-09-23T21:00:06.222401+09:00"}
|
||||
{"date": "2026-09-23", "market": "KOSPI", "market_label": "코스피", "rise": 311, "upper": 0, "fall": 548, "lower": 0, "steady": 55, "adr": 56.75, "personal": -14649, "foreign": -4942, "institutional": 3189, "captured_at": "2026-09-23T21:00:06.222401+09:00"}
|
||||
{"date": "2026-09-28", "market": "KOSDAQ", "market_label": "코스닥", "rise": 967, "upper": 11, "fall": 681, "lower": 3, "steady": 88, "adr": 142.98, "personal": 223, "foreign": 469, "institutional": -747, "captured_at": "2026-09-28T21:00:06.010555+09:00"}
|
||||
{"date": "2026-09-28", "market": "KOSPI", "market_label": "코스피", "rise": 611, "upper": 1, "fall": 249, "lower": 0, "steady": 54, "adr": 245.78, "personal": 26467, "foreign": -32682, "institutional": -10169, "captured_at": "2026-09-28T21:00:06.010555+09:00"}
|
||||
{"date": "2026-09-29", "market": "KOSDAQ", "market_label": "코스닥", "rise": 571, "upper": 4, "fall": 1084, "lower": 5, "steady": 87, "adr": 52.8, "personal": 1469, "foreign": -1305, "institutional": 66, "captured_at": "2026-09-29T21:00:05.779795+09:00"}
|
||||
{"date": "2026-09-29", "market": "KOSPI", "market_label": "코스피", "rise": 232, "upper": 0, "fall": 624, "lower": 0, "steady": 58, "adr": 37.18, "personal": 12447, "foreign": -29922, "institutional": 1041, "captured_at": "2026-09-29T21:00:05.779795+09:00"}
|
||||
{"date": "2026-09-30", "market": "KOSDAQ", "market_label": "코스닥", "rise": 804, "upper": 3, "fall": 845, "lower": 2, "steady": 94, "adr": 95.28, "personal": 188, "foreign": -713, "institutional": 482, "captured_at": "2026-09-30T21:00:02.664980+09:00"}
|
||||
{"date": "2026-09-30", "market": "KOSPI", "market_label": "코스피", "rise": 352, "upper": 1, "fall": 513, "lower": 0, "steady": 49, "adr": 68.81, "personal": 12109, "foreign": -20646, "institutional": -7993, "captured_at": "2026-09-30T21:00:02.664980+09:00"}
|
||||
{"date": "2026-10-01", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1230, "upper": 2, "fall": 423, "lower": 0, "steady": 89, "adr": 291.25, "personal": -7770, "foreign": 2280, "institutional": 5684, "captured_at": "2026-10-01T21:00:02.087388+09:00"}
|
||||
{"date": "2026-10-01", "market": "KOSPI", "market_label": "코스피", "rise": 542, "upper": 0, "fall": 332, "lower": 0, "steady": 42, "adr": 163.25, "personal": -14102, "foreign": -5558, "institutional": 3239, "captured_at": "2026-10-01T21:00:02.087388+09:00"}
|
||||
{"date": "2026-10-02", "market": "KOSDAQ", "market_label": "코스닥", "rise": 958, "upper": 4, "fall": 681, "lower": 0, "steady": 102, "adr": 141.26, "personal": 841, "foreign": -1767, "institutional": 936, "captured_at": "2026-10-02T21:00:06.325959+09:00"}
|
||||
{"date": "2026-10-02", "market": "KOSPI", "market_label": "코스피", "rise": 500, "upper": 1, "fall": 362, "lower": 0, "steady": 56, "adr": 138.4, "personal": -17897, "foreign": -1080, "institutional": 4155, "captured_at": "2026-10-02T21:00:06.325959+09:00"}
|
||||
{"date": "2026-10-06", "market": "KOSDAQ", "market_label": "코스닥", "rise": 1012, "upper": 6, "fall": 645, "lower": 0, "steady": 81, "adr": 157.83, "personal": -2111, "foreign": 1073, "institutional": 1047, "captured_at": "2026-10-06T21:00:05.141855+09:00"}
|
||||
{"date": "2026-10-06", "market": "KOSPI", "market_label": "코스피", "rise": 425, "upper": 0, "fall": 461, "lower": 0, "steady": 32, "adr": 92.19, "personal": 7441, "foreign": -17575, "institutional": -39, "captured_at": "2026-10-06T21:00:05.141855+09:00"}
|
||||
{"date": "2026-10-07", "market": "KOSDAQ", "market_label": "코스닥", "rise": 596, "upper": 3, "fall": 1060, "lower": 0, "steady": 85, "adr": 56.51, "personal": 4871, "foreign": -2495, "institutional": -2102, "captured_at": "2026-10-07T21:00:04.754118+09:00"}
|
||||
{"date": "2026-10-07", "market": "KOSPI", "market_label": "코스피", "rise": 333, "upper": 3, "fall": 527, "lower": 0, "steady": 57, "adr": 63.76, "personal": 26183, "foreign": -26188, "institutional": -7101, "captured_at": "2026-10-07T21:00:04.754118+09:00"}
|
||||
{"date": "2026-10-08", "market": "KOSDAQ", "market_label": "코스닥", "rise": 618, "upper": 13, "fall": 1031, "lower": 1, "steady": 88, "adr": 61.14, "personal": 483, "foreign": 625, "institutional": -1075, "captured_at": "2026-10-08T21:00:05.577237+09:00"}
|
||||
{"date": "2026-10-08", "market": "KOSPI", "market_label": "코스피", "rise": 252, "upper": 3, "fall": 625, "lower": 0, "steady": 40, "adr": 40.8, "personal": 29972, "foreign": -20704, "institutional": -16789, "captured_at": "2026-10-08T21:00:05.577237+09:00"}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{"👀": "v1"}
|
||||
{"👀": "v1", "⭐": "v4", "🔥": "v300", "💎": "v40", "🚀": "v319", "🎯": "v321"}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,204 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"cash": 11759121.887999972,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.943184+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.113024+09:00"
|
||||
"positions": {
|
||||
"010950": {
|
||||
"code": "010950",
|
||||
"name": "S-Oil",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 86,
|
||||
"entry_price": 128395.3488372093,
|
||||
"entry_at": "2026-07-08T09:16:01.874405+09:00",
|
||||
"stop": 112596,
|
||||
"target": 175793,
|
||||
"peak": 173500,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 169000,
|
||||
"tranches": 2,
|
||||
"tranche_value": 5572914.42378125,
|
||||
"last_add_price": 130800
|
||||
},
|
||||
"194370": {
|
||||
"code": "194370",
|
||||
"name": "제이에스코퍼레이션",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 728,
|
||||
"entry_price": 13160.096153846154,
|
||||
"entry_at": "2026-08-03T12:00:10.581409+09:00",
|
||||
"stop": 11445,
|
||||
"target": 18305,
|
||||
"peak": 14150,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 12200,
|
||||
"tranches": 2,
|
||||
"tranche_value": 4803063.4110624995,
|
||||
"last_add_price": 13420
|
||||
},
|
||||
"041520": {
|
||||
"code": "041520",
|
||||
"name": "이엘씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 479,
|
||||
"entry_price": 11636.137787056368,
|
||||
"entry_at": "2026-09-03T09:02:19.208652+09:00",
|
||||
"stop": 10093,
|
||||
"target": 16265,
|
||||
"peak": 13200,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 11,601 도달",
|
||||
"cur_price": 12790,
|
||||
"tranches": 2,
|
||||
"tranche_value": 4801549.203874999,
|
||||
"last_add_price": 11930
|
||||
},
|
||||
"252990": {
|
||||
"code": "252990",
|
||||
"name": "샘씨엔에스",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 550,
|
||||
"entry_price": 13428.945454545454,
|
||||
"entry_at": "2026-09-04T10:32:21.056829+09:00",
|
||||
"stop": 10340,
|
||||
"target": 22696,
|
||||
"peak": 20700,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 13,168 도달",
|
||||
"cur_price": 18340,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3703169.8275951464,
|
||||
"last_add_price": 13820
|
||||
},
|
||||
"088350": {
|
||||
"code": "088350",
|
||||
"name": "한화생명",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 817,
|
||||
"entry_price": 5740,
|
||||
"entry_at": "2026-09-22T09:32:35.675924+09:00",
|
||||
"stop": 5129,
|
||||
"target": 7573,
|
||||
"peak": 5900,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 5190,
|
||||
"tranches": 1,
|
||||
"tranche_value": 4695809.036093748,
|
||||
"last_add_price": 5740
|
||||
},
|
||||
"161890": {
|
||||
"code": "161890",
|
||||
"name": "한국콜마",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 34,
|
||||
"entry_price": 136600,
|
||||
"entry_at": "2026-10-02T14:48:37.320234+09:00",
|
||||
"stop": 116526,
|
||||
"target": 196823,
|
||||
"peak": 148900,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 138400,
|
||||
"tranches": 1,
|
||||
"tranche_value": 4696049.632218748,
|
||||
"last_add_price": 136600
|
||||
},
|
||||
"108490": {
|
||||
"code": "108490",
|
||||
"name": "로보티즈",
|
||||
"sources": [
|
||||
"interest"
|
||||
],
|
||||
"qty": 14,
|
||||
"entry_price": 316500,
|
||||
"entry_at": "2026-10-06T13:48:38.139356+09:00",
|
||||
"stop": 268844,
|
||||
"target": 459469,
|
||||
"peak": 323500,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 299500,
|
||||
"tranches": 1,
|
||||
"tranche_value": 4645722.400968748,
|
||||
"last_add_price": 316500
|
||||
},
|
||||
"295310": {
|
||||
"code": "295310",
|
||||
"name": "에이치브이엠",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 127,
|
||||
"entry_price": 72011.02362204724,
|
||||
"entry_at": "2026-10-08T09:06:38.453273+09:00",
|
||||
"stop": 62774,
|
||||
"target": 99722,
|
||||
"peak": 76700,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 74200,
|
||||
"tranches": 2,
|
||||
"tranche_value": 4640247.106124998,
|
||||
"last_add_price": 73700
|
||||
}
|
||||
},
|
||||
"realized_pnl": -31739844.228000037,
|
||||
"closed_trades": 35,
|
||||
"wins": 1,
|
||||
"peak_equity": 100102462.48,
|
||||
"max_drawdown": 0.2711544112406138,
|
||||
"last_exit": {
|
||||
"030000": "2026-08-06",
|
||||
"086790": "2026-07-29",
|
||||
"005930": "2026-06-23",
|
||||
"298040": "2026-06-24",
|
||||
"403870": "2026-07-07",
|
||||
"240810": "2026-07-24",
|
||||
"093370": "2026-07-20",
|
||||
"214450": "2026-10-02",
|
||||
"055550": "2026-07-20",
|
||||
"024060": "2026-07-27",
|
||||
"003680": "2026-08-24",
|
||||
"252990": "2026-09-01",
|
||||
"078930": "2026-09-22",
|
||||
"090850": "2026-08-06",
|
||||
"138040": "2026-08-14",
|
||||
"257720": "2026-09-04",
|
||||
"068270": "2026-09-04",
|
||||
"327260": "2026-09-07",
|
||||
"003550": "2026-09-15",
|
||||
"000720": "2026-10-06",
|
||||
"316140": "2026-10-08"
|
||||
},
|
||||
"created_at": "2026-06-11T09:00:01.943184+09:00",
|
||||
"updated_at": "2026-10-08T15:28:36.335052+09:00"
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{"ts": "2026-06-17T11:15:02.163140+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 130900, "qty": 47, "cost": 6153223, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 118039, "target": 169483, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "129,300 vs 118,950"}, {"label": "정배열(5>10)", "ok": true, "detail": "120,400 / 118,950"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "120,400 / 118,307"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-13.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +371 · 기 +158 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+22%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.12% (환산) vs 평균 0.38% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 126,500"}, {"label": "거래량 급증", "ok": true, "detail": "3,094,534 (환산) vs 평균 1,031,725"}, {"label": "회전율 급증", "ok": true, "detail": "1.12% (환산) vs 평균 0.38%"}, {"label": "RSI", "ok": true, "detail": "60"}]}
|
||||
{"ts": "2026-06-19T09:02:05.756730+09:00", "side": "BUY", "code": "005930", "name": "삼성전자", "price": 373500, "qty": 16, "cost": 5976896, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 331259, "target": 500222, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "373,000 vs 330,350"}, {"label": "정배열(5>10)", "ok": true, "detail": "352,400 / 330,350"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "352,400 / 258,005"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,427 · 기 +4,448 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+19%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.04% (환산) vs 평균 0.53% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 370,000"}, {"label": "거래량 급증", "ok": true, "detail": "60,950,213 (환산) vs 평균 31,088,390"}, {"label": "회전율 급증", "ok": true, "detail": "1.04% (환산) vs 평균 0.53%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-06-19T09:02:06.178128+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19160, "qty": 325, "cost": 6227934, "path": "pullback", "reason": "눌림목 지정가 19,690 도달", "stop": 17760, "target": 23360, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,150 vs 18,963"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,616 / 18,963"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,616 / 19,361"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-31.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,162 · 기 +2,080 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "37,673 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,690 (현재 19,150)"}]}
|
||||
{"ts": "2026-06-23T09:00:07.147411+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18820, "qty": 325, "proceeds": 6104573, "entry_price": 19160, "pnl": -123361, "pnl_pct": -1.77, "hold_from": "2026-06-19T09:02:06.178112+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,710)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 23,360"}, {"label": "추세(10일선)", "ok": false, "detail": "18,919"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -318 · 기 -227"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,528"}]}
|
||||
{"ts": "2026-06-23T09:06:02.261681+09:00", "side": "BUY", "code": "298040", "name": "효성중공업", "price": 4130000, "qty": 1, "cost": 4130620, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 3559830, "target": 5840510, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "4,125,000 vs 3,535,900"}, {"label": "정배열(5>10)", "ok": true, "detail": "3,678,200 / 3,535,900"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "3,678,200 / 3,411,683"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-6.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +16 · 기 -7 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.82% (환산) vs 평균 0.63% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 4,085,000"}, {"label": "거래량 급증", "ok": true, "detail": "170,653 (환산) vs 평균 58,834"}, {"label": "회전율 급증", "ok": true, "detail": "1.82% (환산) vs 평균 0.63%"}, {"label": "RSI", "ok": true, "detail": "62"}]}
|
||||
{"ts": "2026-06-23T12:04:03.036279+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 117700, "qty": 47, "proceeds": 5521113, "entry_price": 130900, "pnl": -632110, "pnl_pct": -10.08, "hold_from": "2026-06-17T11:15:02.163131+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "118,039 (현재 117,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 169,483"}, {"label": "추세(10일선)", "ok": false, "detail": "119,330"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +332 · 기 -364"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 134,300"}]}
|
||||
{"ts": "2026-06-23T12:32:03.300082+09:00", "side": "SELL", "code": "005930", "name": "삼성전자", "price": 330000, "qty": 16, "proceeds": 5269704, "entry_price": 373500, "pnl": -707192, "pnl_pct": -11.65, "hold_from": "2026-06-19T09:02:05.756719+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "331,259 (현재 330,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 500,222"}, {"label": "추세(10일선)", "ok": false, "detail": "335,050"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -3,792 · 기 +1,417"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 384,779"}]}
|
||||
{"ts": "2026-06-24T09:02:01.876083+09:00", "side": "SELL", "code": "298040", "name": "효성중공업", "price": 3499000, "qty": 1, "proceeds": 3492177, "entry_price": 4130000, "pnl": -638443, "pnl_pct": -15.28, "hold_from": "2026-06-23T09:06:02.261677+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "3,559,830 (현재 3,489,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 5,840,510"}, {"label": "추세(10일선)", "ok": true, "detail": "3,472,300"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +19 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 4,274,721"}]}
|
||||
{"ts": "2026-06-24T09:02:02.294346+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 53200, "qty": 97, "cost": 5161174, "path": "pullback", "reason": "눌림목 지정가 60,114 도달", "stop": 43200, "target": 83200, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "53,200 vs 52,550"}, {"label": "정배열(5>10)", "ok": true, "detail": "57,960 / 52,550"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "57,960 / 49,671"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +711 · 기 -2,060 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+29%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.34% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "1,102,733 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "1.34% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 60,114 (현재 53,200)"}]}
|
||||
{"ts": "2026-06-24T09:08:00.922591+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 155400, "qty": 26, "cost": 4041006, "path": "pullback", "reason": "눌림목 지정가 155,963 도달", "stop": 118951, "target": 264748, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "155,400 vs 128,090"}, {"label": "정배열(5>10)", "ok": true, "detail": "143,100 / 128,090"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "143,100 / 121,690"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+45.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +143 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+6%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "6.50% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "3,192,593 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "6.50% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 155,963 (현재 155,400)"}]}
|
||||
{"ts": "2026-06-26T09:00:03.477186+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 50000, "qty": 97, "proceeds": 4840542, "entry_price": 53200, "pnl": -320632, "pnl_pct": -6.02, "hold_from": "2026-06-24T09:02:02.294329+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "43,200 (현재 51,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 83,200"}, {"label": "추세(10일선)", "ok": false, "detail": "52,330"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -311 · 기 -267"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 57,056"}]}
|
||||
{"ts": "2026-06-26T09:04:03.833814+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 164900, "qty": 25, "cost": 4123118, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 118951, "target": 264748, "signals": [{"label": "손절선", "ok": true, "detail": "118,951 (현재 164,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 264,748"}, {"label": "추세(10일선)", "ok": true, "detail": "129,020"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -140 · 기 +323"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 164,359"}]}
|
||||
{"ts": "2026-06-29T09:00:12.226221+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 339500, "qty": 18, "cost": 6111917, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 300291, "target": 457126, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "337,000 vs 305,050"}, {"label": "정배열(5>10)", "ok": true, "detail": "313,600 / 305,050"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "313,600 / 305,367"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+37.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -22 · 기 +97 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+43%"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.50% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "155,347 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.50% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-06-29T09:40:01.364512+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 351000, "qty": 17, "cost": 5967895, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 300291, "target": 457126, "signals": [{"label": "손절선", "ok": true, "detail": "300,291 (현재 351,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 457,126"}, {"label": "추세(10일선)", "ok": true, "detail": "306,450"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -22 · 기 +97"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 349,802"}]}
|
||||
{"ts": "2026-06-30T09:00:03.224282+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 55300, "qty": 80, "cost": 4424664, "path": "pullback", "reason": "눌림목 지정가 60,222 도달", "stop": 43200, "target": 91600, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "53,700 vs 52,600"}, {"label": "정배열(5>10)", "ok": true, "detail": "58,060 / 52,600"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "58,060 / 49,679"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +222 · 기 -821 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+28%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "827 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 60,222 (현재 53,700)"}]}
|
||||
{"ts": "2026-07-02T10:18:02.949133+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 15980, "qty": 274, "cost": 4379177, "path": "pullback", "reason": "눌림목 지정가 16,358 도달", "stop": 12444, "target": 26587, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "15,980 vs 13,620"}, {"label": "정배열(5>10)", "ok": true, "detail": "16,104 / 13,620"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "16,104 / 11,478"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+25.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,833 · 기 +437 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+25%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "9.98% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": true, "detail": "10,701,165 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": true, "detail": "9.98% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 16,358 (현재 15,980)"}]}
|
||||
{"ts": "2026-07-02T10:40:02.874484+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18960, "qty": 319, "cost": 6049147, "path": "pullback", "reason": "눌림목 지정가 19,580 도달", "stop": 17760, "target": 22560, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,960 vs 18,944"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,578 / 18,944"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,578 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -339 · 기 +110 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.86% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": true, "detail": "986,112 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.86% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,580 (현재 18,960)"}]}
|
||||
{"ts": "2026-07-02T11:20:01.970932+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 22550, "qty": 268, "cost": 6044307, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 22560, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 19,350)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,560"}, {"label": "추세(10일선)", "ok": true, "detail": "18,983"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,350"}]}
|
||||
{"ts": "2026-07-02T11:24:06.075978+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18860, "qty": 587, "proceeds": 11049232, "entry_price": 20599, "pnl": -1044222, "pnl_pct": -8.44, "hold_from": "2026-07-02T10:40:02.874471+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "19,039 (현재 18,890)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 25,279"}, {"label": "추세(10일선)", "ok": false, "detail": "18,937"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T09:44:02.531757+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 123600, "qty": 51, "proceeds": 6291308, "entry_price": 160057, "pnl": -1872816, "pnl_pct": -22.78, "hold_from": "2026-06-24T09:08:00.922587+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "124,222 (현재 123,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 267,562"}, {"label": "추세(10일선)", "ok": false, "detail": "124,920"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +674 · 기 -2,170"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T10:24:02.696490+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19040, "qty": 306, "cost": 5827114, "path": "pullback", "reason": "눌림목 지정가 19,631 도달", "stop": 17760, "target": 22880, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,030 vs 18,951"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,592 / 18,951"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,592 / 19,359"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -35 · 기 +168 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.54% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "619,193 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.54% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,631 (현재 19,030)"}]}
|
||||
{"ts": "2026-07-06T09:00:05.367354+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 130600, "qty": 27, "cost": 3526729, "path": "pullback", "reason": "눌림목 지정가 154,452 도달", "stop": 96000, "target": 234400, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "133,100 vs 125,860"}, {"label": "정배열(5>10)", "ok": true, "detail": "138,640 / 125,860"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "138,640 / 121,318"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +782 · 기 -1,979 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+31%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.01% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": false, "detail": "4,080 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": false, "detail": "0.01% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 154,452 (현재 133,100)"}]}
|
||||
{"ts": "2026-07-06T09:34:01.582045+09:00", "side": "BUY", "code": "055550", "name": "신한지주", "price": 110000, "qty": 52, "cost": 5720858, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 99202, "target": 142394, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "110,300 vs 99,990"}, {"label": "정배열(5>10)", "ok": true, "detail": "101,240 / 99,990"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "101,240 / 96,630"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -717 · 기 +1,343 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.55% (환산) vs 평균 0.35% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 109,800"}, {"label": "거래량 급증", "ok": true, "detail": "2,617,853 (환산) vs 평균 1,653,581"}, {"label": "회전율 급증", "ok": true, "detail": "0.55% (환산) vs 평균 0.35%"}, {"label": "RSI", "ok": true, "detail": "64"}]}
|
||||
{"ts": "2026-07-07T09:48:07.302373+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 43000, "qty": 80, "proceeds": 3433292, "entry_price": 55300, "pnl": -991372, "pnl_pct": -22.24, "hold_from": "2026-06-30T09:00:03.224267+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "43,200 (현재 43,050)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 91,600"}, {"label": "추세(10일선)", "ok": false, "detail": "51,535"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +1,424 · 기 -5,706"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 59,449"}]}
|
||||
{"ts": "2026-07-07T10:28:06.382153+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 12410, "qty": 274, "proceeds": 3393709, "entry_price": 15980, "pnl": -985467, "pnl_pct": -22.34, "hold_from": "2026-07-02T10:18:02.949119+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "12,444 (현재 12,430)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 26,587"}, {"label": "추세(10일선)", "ok": false, "detail": "13,265"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,543 · 기 +15"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 16,975"}]}
|
||||
{"ts": "2026-07-07T14:06:20.089623+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 183300, "qty": 19, "cost": 3483222, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 96000, "target": 234400, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 183,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 234,400"}, {"label": "추세(10일선)", "ok": true, "detail": "123,150"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +263 · 기 -1,543"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 139,025"}]}
|
||||
{"ts": "2026-07-07T14:06:22.687123+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 20750, "qty": 281, "cost": 5831625, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 22880, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,880"}, {"label": "추세(10일선)", "ok": true, "detail": "18,901"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 +113"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,358"}]}
|
||||
{"ts": "2026-07-08T09:00:10.790205+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 108300, "qty": 46, "proceeds": 4972085, "entry_price": 152367, "pnl": -2037866, "pnl_pct": -28.92, "hold_from": "2026-07-06T09:00:05.367339+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "118,668 (현재 112,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 253,466"}, {"label": "추세(10일선)", "ok": false, "detail": "123,820"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +243 · 기 -857"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-08T09:16:01.874414+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 126100, "qty": 44, "cost": 5549232, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 110301, "target": 173498, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "126,100 vs 108,080"}, {"label": "정배열(5>10)", "ok": true, "detail": "114,400 / 108,080"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "114,400 / 113,132"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+16.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -164 · 기 +736 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+18%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.48% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "544,247 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.48% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-08T09:34:04.862709+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 130800, "qty": 42, "cost": 5494424, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 110301, "target": 173498, "signals": [{"label": "손절선", "ok": true, "detail": "110,301 (현재 130,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 173,498"}, {"label": "추세(10일선)", "ok": true, "detail": "108,520"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -164 · 기 +736"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 130,050"}]}
|
||||
{"ts": "2026-07-10T09:26:06.360793+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 130900, "qty": 42, "cost": 5498625, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 117686, "target": 170541, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "130,800 vs 120,640"}, {"label": "정배열(5>10)", "ok": true, "detail": "122,760 / 120,640"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "122,760 / 118,578"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+18.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -427 · 기 +555 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+23%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.60% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": true, "detail": "1,646,513 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": true, "detail": "0.60% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "61"}]}
|
||||
{"ts": "2026-07-10T11:06:06.387602+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 13660, "qty": 271, "cost": 3702415, "path": "pullback", "reason": "눌림목 지정가 15,971 도달", "stop": 10360, "target": 23560, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,630 vs 13,385"}, {"label": "정배열(5>10)", "ok": true, "detail": "15,634 / 13,385"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,634 / 11,439"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +239 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+47%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "14.23% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": true, "detail": "15,267,605 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": true, "detail": "14.23% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 15,971 (현재 13,630)"}]}
|
||||
{"ts": "2026-07-10T12:14:02.591560+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 126200, "qty": 29, "cost": 3660349, "path": "pullback", "reason": "눌림목 지정가 151,752 도달", "stop": 96000, "target": 216800, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "126,200 vs 125,170"}, {"label": "정배열(5>10)", "ok": true, "detail": "137,260 / 125,170"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "137,260 / 121,203"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +483 · 기 -241 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "7.53% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "3,695,762 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "7.53% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 151,752 (현재 126,200)"}]}
|
||||
{"ts": "2026-07-13T09:06:06.491483+09:00", "side": "BUY", "code": "055550", "name": "신한지주", "price": 112800, "qty": 50, "cost": 5640846, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 99202, "target": 142394, "signals": [{"label": "손절선", "ok": true, "detail": "99,202 (현재 112,800)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 142,394"}, {"label": "추세(10일선)", "ok": true, "detail": "100,240"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -358 · 기 +991"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 112,792"}]}
|
||||
{"ts": "2026-07-13T09:08:05.274334+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 134300, "qty": 41, "cost": 5507126, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 117686, "target": 170541, "signals": [{"label": "손절선", "ok": true, "detail": "117,686 (현재 134,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 170,541"}, {"label": "추세(10일선)", "ok": true, "detail": "120,980"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -277 · 기 +460"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 134,118"}]}
|
||||
{"ts": "2026-07-13T09:26:03.332430+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 139100, "qty": 27, "cost": 3756263, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 96000, "target": 216800, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 139,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 216,800"}, {"label": "추세(10일선)", "ok": true, "detail": "126,460"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -213 · 기 +772"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 136,162"}]}
|
||||
{"ts": "2026-07-14T09:40:04.963062+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 5910, "qty": 931, "cost": 5503035, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5447, "target": 7298, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "5,900 vs 5,543"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,646 / 5,543"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,646 / 5,614"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+33.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +107 · 기 +5 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.34% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "74,367 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.34% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "65"}]}
|
||||
{"ts": "2026-07-15T10:30:01.562473+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 303500, "qty": 35, "proceeds": 10601786, "entry_price": 345086, "pnl": -1478026, "pnl_pct": -12.05, "hold_from": "2026-06-29T09:00:12.226210+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "303,877 (현재 303,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 468,711"}, {"label": "추세(10일선)", "ok": true, "detail": "301,700"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -25 · 기 +35"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-15T13:56:01.759138+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6030, "qty": 912, "cost": 5500185, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5447, "target": 7298, "signals": [{"label": "손절선", "ok": true, "detail": "5,447 (현재 6,030)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,298"}, {"label": "추세(10일선)", "ok": true, "detail": "5,556"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +147 · 기 -11"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,028"}]}
|
||||
{"ts": "2026-07-15T14:32:01.808579+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18570, "qty": 587, "proceeds": 10879334, "entry_price": 19859, "pnl": -779405, "pnl_pct": -6.49, "hold_from": "2026-07-03T10:24:02.696479+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "18,586 (현재 18,570)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 23,675"}, {"label": "추세(10일선)", "ok": false, "detail": "18,905"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -214 · 기 +240"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-16T09:04:02.184872+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 341500, "qty": 16, "cost": 5464820, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 301363, "target": 461911, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "340,500 vs 305,400"}, {"label": "정배열(5>10)", "ok": true, "detail": "314,300 / 305,400"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "314,300 / 305,425"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+34.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -33 · 기 +28 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.18% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "226,533 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "2.18% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "64"}]}
|
||||
{"ts": "2026-07-16T09:28:03.406225+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 353000, "qty": 15, "cost": 5295794, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 301363, "target": 461911, "signals": [{"label": "손절선", "ok": true, "detail": "301,363 (현재 352,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 461,911"}, {"label": "추세(10일선)", "ok": true, "detail": "306,600"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -33 · 기 +28"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 351,963"}]}
|
||||
{"ts": "2026-07-20T12:28:02.248848+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 10310, "qty": 271, "proceeds": 2788562, "entry_price": 13660, "pnl": -913854, "pnl_pct": -24.52, "hold_from": "2026-07-10T11:06:06.387588+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "10,360 (현재 10,310)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 23,560"}, {"label": "추세(10일선)", "ok": false, "detail": "12,013"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -887 · 기 +515"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,626"}]}
|
||||
{"ts": "2026-07-20T14:12:02.314100+09:00", "side": "SELL", "code": "055550", "name": "신한지주", "price": 100100, "qty": 102, "proceeds": 10190290, "entry_price": 111373, "pnl": -1171414, "pnl_pct": -10.12, "hold_from": "2026-07-06T09:34:01.582032+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "100,203 (현재 100,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 144,881"}, {"label": "추세(10일선)", "ok": true, "detail": "98,970"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -168 · 기 +257"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-23T09:46:06.299582+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 88400, "qty": 61, "cost": 5393209, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 77778, "target": 120266, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "88,300 vs 80,910"}, {"label": "정배열(5>10)", "ok": true, "detail": "83,020 / 80,910"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,020 / 75,060"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+47.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -150 · 기 +299 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.80% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "743,133 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.80% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-07-23T10:30:02.401768+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 14140, "qty": 356, "cost": 5034595, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11706, "target": 21442, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,180 vs 12,429"}, {"label": "정배열(5>10)", "ok": true, "detail": "13,154 / 12,429"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "13,154 / 12,687"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+55.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +109 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "84.26% (환산) vs 평균 31.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 13,990"}, {"label": "거래량 급증", "ok": true, "detail": "12,639,979 (환산) vs 평균 4,720,532"}, {"label": "회전율 급증", "ok": true, "detail": "84.26% (환산) vs 평균 31.47%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-23T13:06:04.378524+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 91400, "qty": 59, "cost": 5393409, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 77778, "target": 120266, "signals": [{"label": "손절선", "ok": true, "detail": "77,778 (현재 91,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 120,266"}, {"label": "추세(10일선)", "ok": true, "detail": "81,220"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -169 · 기 +311"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 91,159"}]}
|
||||
{"ts": "2026-07-24T09:00:02.274051+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 111800, "qty": 56, "proceeds": 6248591, "entry_price": 132420, "pnl": -1168021, "pnl_pct": -15.57, "hold_from": "2026-07-10T12:14:02.591547+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 113,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 241,679"}, {"label": "추세(10일선)", "ok": false, "detail": "123,880"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -480 · 기 -206"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-24T09:02:03.981423+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 15060, "qty": 334, "cost": 5030795, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11706, "target": 21442, "signals": [{"label": "손절선", "ok": true, "detail": "11,706 (현재 15,020)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,442"}, {"label": "추세(10일선)", "ok": true, "detail": "12,513"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +208 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,766"}]}
|
||||
{"ts": "2026-07-24T10:02:04.270583+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18950, "qty": 292, "cost": 5534230, "path": "pullback", "reason": "눌림목 지정가 19,643 도달", "stop": 17760, "target": 22520, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,950 vs 18,943"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,576 / 18,943"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,576 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+19.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +2 · 기 -5 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.39% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "454,069 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.39% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,643 (현재 18,950)"}]}
|
||||
{"ts": "2026-07-27T09:02:05.306008+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7470, "qty": 731, "cost": 5461389, "path": "pullback", "reason": "눌림목 지정가 7,635 도달", "stop": 6370, "target": 10769, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "7,470 vs 5,392"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,452 / 5,392"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,452 / 4,889"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+82.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +49 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.79% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "49,327 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "0.79% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "74"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,635 (현재 7,470)"}]}
|
||||
{"ts": "2026-07-27T09:08:02.026931+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7910, "qty": 690, "cost": 5458719, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 6370, "target": 10769, "signals": [{"label": "손절선", "ok": true, "detail": "6,370 (현재 7,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 10,769"}, {"label": "추세(10일선)", "ok": true, "detail": "5,434"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +49 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,747"}]}
|
||||
{"ts": "2026-07-27T09:10:00.979478+09:00", "side": "SELL", "code": "024060", "name": "흥구석유", "price": 12030, "qty": 690, "proceeds": 8284514, "entry_price": 14585, "pnl": -1780876, "pnl_pct": -17.52, "hold_from": "2026-07-23T10:30:02.401758+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "12,081 (현재 12,030)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,097"}, {"label": "추세(10일선)", "ok": false, "detail": "12,214"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -85 · 기 +2"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-27T09:12:01.035449+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13150, "qty": 306, "cost": 4024504, "path": "pullback", "reason": "눌림목 지정가 13,180 도달", "stop": 10340, "target": 21580, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,150 vs 12,888"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,378 / 12,888"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,378 / 13,268"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+1.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +27 · 기 +15 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.46% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "274,967 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.46% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,180 (현재 13,150)"}]}
|
||||
{"ts": "2026-07-28T11:22:00.688753+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 6630, "qty": 1421, "proceeds": 9402859, "entry_price": 7684, "pnl": -1517249, "pnl_pct": -13.71, "hold_from": "2026-07-27T09:02:05.306004+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "6,577 (현재 6,380)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 11,004"}, {"label": "추세(10일선)", "ok": true, "detail": "5,282"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +96 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:02:01.303424+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6540, "qty": 654, "cost": 4277802, "path": "pullback", "reason": "눌림목 지정가 7,526 도달", "stop": 5295, "target": 10276, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,540 vs 5,298"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,266 / 5,298"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,266 / 4,873"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+70.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +188 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.87% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "116,047 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "1.87% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "63"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,526 (현재 6,540)"}]}
|
||||
{"ts": "2026-07-29T09:24:03.681936+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18950, "qty": 292, "cost": 5534230, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 22520, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,520"}, {"label": "추세(10일선)", "ok": true, "detail": "18,901"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +99 · 기 +138"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,268"}]}
|
||||
{"ts": "2026-07-29T09:24:04.035867+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6710, "qty": 637, "cost": 4274911, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5295, "target": 10276, "signals": [{"label": "손절선", "ok": true, "detail": "5,295 (현재 8,460)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 10,276"}, {"label": "추세(10일선)", "ok": true, "detail": "5,064"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +188 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,796"}]}
|
||||
{"ts": "2026-07-29T09:24:04.447212+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 11310, "qty": 317, "cost": 3585808, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10340, "target": 21580, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 15,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,580"}, {"label": "추세(10일선)", "ok": true, "detail": "12,768"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +242 · 기 -182"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,887"}]}
|
||||
{"ts": "2026-07-29T10:52:02.292514+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 10340, "qty": 623, "proceeds": 6429258, "entry_price": 12214, "pnl": -1181053, "pnl_pct": -15.34, "hold_from": "2026-07-27T09:12:01.035446+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "10,340 (현재 10,340)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,835"}, {"label": "추세(10일선)", "ok": false, "detail": "12,607"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +239 · 기 -181"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T12:04:01.628867+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 119200, "qty": 83, "proceeds": 9874307, "entry_price": 132580, "pnl": -1131443, "pnl_pct": -10.09, "hold_from": "2026-07-10T09:26:06.360780+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "119,709 (현재 119,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 171,192"}, {"label": "추세(10일선)", "ok": false, "detail": "119,490"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -11 · 기 -51"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T13:14:05.383429+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 5580, "qty": 1291, "proceeds": 7189733, "entry_price": 6624, "pnl": -1362980, "pnl_pct": -15.76, "hold_from": "2026-07-29T09:02:01.303420+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,600 (현재 5,590)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,697"}, {"label": "추세(10일선)", "ok": true, "detail": "5,204"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +185 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-30T09:04:05.277102+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6130, "qty": 589, "cost": 3611112, "path": "pullback", "reason": "눌림목 지정가 7,457 도달", "stop": 4793, "target": 10140, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,120 vs 5,256"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,182 / 5,256"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,182 / 4,866"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+65.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +234 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.31% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "267,687 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "4.31% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,457 (현재 6,120)"}]}
|
||||
{"ts": "2026-07-30T09:16:09.704313+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 124100, "qty": 40, "cost": 4964745, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 113673, "target": 155380, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "124,000 vs 117,010"}, {"label": "정배열(5>10)", "ok": true, "detail": "118,260 / 117,010"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "118,260 / 110,085"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+54.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +9 · 기 +389 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+20%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.31% (환산) vs 평균 0.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 123,300"}, {"label": "거래량 급증", "ok": true, "detail": "518,220 (환산) vs 평균 289,522"}, {"label": "회전율 급증", "ok": true, "detail": "0.31% (환산) vs 평균 0.17%"}, {"label": "RSI", "ok": true, "detail": "68"}]}
|
||||
{"ts": "2026-07-30T10:10:06.142998+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 127700, "qty": 39, "cost": 4981047, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 113673, "target": 155380, "signals": [{"label": "손절선", "ok": true, "detail": "113,673 (현재 127,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 155,380"}, {"label": "추세(10일선)", "ok": true, "detail": "117,370"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +35 · 기 +392"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 126,835"}]}
|
||||
{"ts": "2026-07-31T09:06:05.825941+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6550, "qty": 551, "cost": 3609591, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 4793, "target": 10140, "signals": [{"label": "손절선", "ok": true, "detail": "4,793 (현재 6,640)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 10,140"}, {"label": "추세(10일선)", "ok": true, "detail": "5,308"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +355 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,453"}]}
|
||||
{"ts": "2026-07-31T09:06:06.330737+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 77700, "qty": 120, "proceeds": 9305818, "entry_price": 89875, "pnl": -1480800, "pnl_pct": -13.55, "hold_from": "2026-07-23T09:46:06.299578+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "78,839 (현재 78,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 122,984"}, {"label": "추세(10일선)", "ok": false, "detail": "79,880"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -179 · 기 +325"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-03T12:00:10.581416+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 12910, "qty": 371, "cost": 4790328, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11314, "target": 17699, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "12,930 vs 12,003"}, {"label": "정배열(5>10)", "ok": true, "detail": "12,138 / 12,003"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "12,138 / 11,924"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +7 · 기 +10 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+74%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.72% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 12,870"}, {"label": "거래량 급증", "ok": true, "detail": "208,994 (환산) vs 평균 99,303"}, {"label": "회전율 급증", "ok": true, "detail": "0.72% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-03T12:02:08.464738+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 13420, "qty": 357, "cost": 4791659, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11314, "target": 17699, "signals": [{"label": "손절선", "ok": true, "detail": "11,314 (현재 13,450)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,699"}, {"label": "추세(10일선)", "ok": true, "detail": "12,055"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +7 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,339"}]}
|
||||
{"ts": "2026-08-04T09:34:08.150391+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 90000, "qty": 53, "cost": 4770716, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 79135, "target": 122595, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "89,800 vs 81,060"}, {"label": "정배열(5>10)", "ok": true, "detail": "83,320 / 81,060"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,320 / 75,085"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+55.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +68 · 기 +117 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+15%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.64% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "595,733 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.64% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-08-04T10:32:11.011486+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 93500, "qty": 13, "cost": 1215682, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 79135, "target": 122595, "signals": [{"label": "손절선", "ok": true, "detail": "79,135 (현재 93,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 122,595"}, {"label": "추세(10일선)", "ok": true, "detail": "81,420"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +57 · 기 +117"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 92,841"}]}
|
||||
{"ts": "2026-08-06T09:00:13.271931+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18730, "qty": 584, "proceeds": 10916990, "entry_price": 18950, "pnl": -151470, "pnl_pct": -1.16, "hold_from": "2026-07-24T10:02:04.270573+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,520"}, {"label": "추세(10일선)", "ok": false, "detail": "18,923"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -203 · 기 -147"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-06T09:06:08.044853+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5470, "qty": 1843, "proceeds": 10061552, "entry_price": 5969, "pnl": -941669, "pnl_pct": -8.37, "hold_from": "2026-07-14T09:40:04.963052+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,498 (현재 5,490)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,383"}, {"label": "추세(10일선)", "ok": false, "detail": "5,502"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -30 · 기 -23"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-06T09:12:11.568337+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13110, "qty": 277, "cost": 3632015, "path": "pullback", "reason": "눌림목 지정가 13,160 도달", "stop": 10340, "target": 21420, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,090 vs 12,882"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,366 / 12,882"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,366 / 13,268"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-12.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -352 · 기 +499 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.38% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "229,113 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.38% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,160 (현재 13,090)"}]}
|
||||
{"ts": "2026-08-06T09:14:08.213833+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 193600, "qty": 24, "cost": 4647097, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 176944, "target": 243568, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "193,500 vs 180,840"}, {"label": "정배열(5>10)", "ok": true, "detail": "186,480 / 180,840"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "186,480 / 176,699"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +255 · 기 -319 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+42%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.49% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 191,700"}, {"label": "거래량 급증", "ok": true, "detail": "1,163,480 (환산) vs 평균 738,783"}, {"label": "회전율 급증", "ok": true, "detail": "0.49% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "64"}]}
|
||||
{"ts": "2026-08-07T09:02:10.128383+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14290, "qty": 254, "cost": 3630204, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10340, "target": 21420, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 14,240)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,420"}, {"label": "추세(10일선)", "ok": true, "detail": "12,997"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -216 · 기 +313"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,856"}]}
|
||||
{"ts": "2026-08-07T09:34:06.949454+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 198600, "qty": 24, "cost": 4767115, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 176944, "target": 243568, "signals": [{"label": "손절선", "ok": true, "detail": "176,944 (현재 198,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 243,568"}, {"label": "추세(10일선)", "ok": true, "detail": "181,350"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +338 · 기 +73"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 197,939"}]}
|
||||
{"ts": "2026-08-14T13:32:13.603290+09:00", "side": "SELL", "code": "138040", "name": "메리츠금융지주", "price": 114900, "qty": 79, "proceeds": 9059400, "entry_price": 125877, "pnl": -886392, "pnl_pct": -8.72, "hold_from": "2026-07-30T09:16:09.704305+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "114,936 (현재 114,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 158,700"}, {"label": "추세(10일선)", "ok": false, "detail": "116,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +29 · 기 +11"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-14T13:34:15.676314+09:00", "side": "BUY", "code": "003550", "name": "LG", "price": 118900, "qty": 42, "cost": 4994549, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 106512, "target": 156063, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "118,800 vs 106,650"}, {"label": "정배열(5>10)", "ok": true, "detail": "110,480 / 106,650"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "110,480 / 107,793"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+14.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +36 · 기 +116 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+24%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.43% (환산) vs 평균 0.19% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 111,900"}, {"label": "거래량 급증", "ok": true, "detail": "662,849 (환산) vs 평균 280,124"}, {"label": "회전율 급증", "ok": true, "detail": "0.43% (환산) vs 평균 0.19%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-08-24T13:12:17.476505+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 5030, "qty": 1140, "proceeds": 5723018, "entry_price": 6333, "pnl": -1497685, "pnl_pct": -20.57, "hold_from": "2026-07-30T09:04:05.277095+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,039 (현재 5,030)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 10,215"}, {"label": "추세(10일선)", "ok": false, "detail": "5,148"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -31 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-24T13:14:17.289907+09:00", "side": "BUY", "code": "327260", "name": "RF머트리얼즈", "price": 32950, "qty": 73, "cost": 2405711, "path": "pullback", "reason": "눌림목 지정가 33,047 도달", "stop": 22546, "target": 64161, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "33,000 vs 28,622"}, {"label": "정배열(5>10)", "ok": true, "detail": "35,100 / 28,622"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "35,100 / 31,286"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+24.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -62 · 기 +65 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+106%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.32% (환산) vs 평균 3.74% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 45,300"}, {"label": "거래량 급증", "ok": false, "detail": "298,285 (환산) vs 평균 409,191"}, {"label": "회전율 급증", "ok": false, "detail": "3.32% (환산) vs 평균 3.74%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 33,047 (현재 33,000)"}]}
|
||||
{"ts": "2026-08-25T13:44:19.077912+09:00", "side": "BUY", "code": "327260", "name": "RF머트리얼즈", "price": 35650, "qty": 68, "cost": 2424564, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 22546, "target": 64161, "signals": [{"label": "손절선", "ok": true, "detail": "22,546 (현재 35,650)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 64,161"}, {"label": "추세(10일선)", "ok": true, "detail": "28,887"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -118 · 기 +90"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 35,565"}]}
|
||||
{"ts": "2026-09-01T09:02:21.139404+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 12660, "qty": 531, "proceeds": 6709351, "entry_price": 13674, "pnl": -552868, "pnl_pct": -7.42, "hold_from": "2026-08-06T09:12:11.568330+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "10,691 (현재 12,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,624"}, {"label": "추세(10일선)", "ok": false, "detail": "12,846"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -53 · 기 -347"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-01T09:08:18.803509+09:00", "side": "BUY", "code": "257720", "name": "실리콘투", "price": 55500, "qty": 90, "cost": 4995749, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 47628, "target": 79116, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "55,400 vs 41,585"}, {"label": "정배열(5>10)", "ok": true, "detail": "44,870 / 41,585"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "44,870 / 36,364"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+48.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +3,572 · 기 -3,450 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+6%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.74% (환산) vs 평균 1.56% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 48,250"}, {"label": "거래량 급증", "ok": true, "detail": "1,796,567 (환산) vs 평균 1,023,767"}, {"label": "회전율 급증", "ok": true, "detail": "2.74% (환산) vs 평균 1.56%"}, {"label": "RSI", "ok": true, "detail": "74"}]}
|
||||
{"ts": "2026-09-03T09:00:26.653781+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 119500, "qty": 66, "proceeds": 7871620, "entry_price": 90689, "pnl": 1885223, "pnl_pct": 31.77, "hold_from": "2026-08-04T09:34:08.150378+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "119,430 (현재 119,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "83,990"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 -35"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-03T09:02:19.208669+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 11590, "qty": 414, "cost": 4798980, "path": "pullback", "reason": "눌림목 지정가 11,601 도달", "stop": 10071, "target": 16146, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "11,590 vs 11,327"}, {"label": "정배열(5>10)", "ok": true, "detail": "11,748 / 11,327"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "11,748 / 9,426"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+28.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +17 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 0.30% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 13,450"}, {"label": "거래량 급증", "ok": false, "detail": "7 (환산) vs 평균 36,789"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 0.30%"}, {"label": "RSI", "ok": true, "detail": "62"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 11,601 (현재 11,590)"}]}
|
||||
{"ts": "2026-09-03T09:56:23.117947+09:00", "side": "BUY", "code": "003550", "name": "LG", "price": 122000, "qty": 41, "cost": 5002750, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 106512, "target": 156063, "signals": [{"label": "손절선", "ok": true, "detail": "106,512 (현재 122,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 156,063"}, {"label": "추세(10일선)", "ok": true, "detail": "106,970"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -119 · 기 +22"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 121,979"}]}
|
||||
{"ts": "2026-09-04T10:30:24.211910+09:00", "side": "SELL", "code": "257720", "name": "실리콘투", "price": 47550, "qty": 90, "proceeds": 4271155, "entry_price": 55500, "pnl": -724594, "pnl_pct": -14.32, "hold_from": "2026-09-01T09:08:18.803493+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "47,628 (현재 47,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 79,116"}, {"label": "추세(10일선)", "ok": true, "detail": "40,805"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +476 · 기 -207"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 57,250"}]}
|
||||
{"ts": "2026-09-04T10:32:21.056845+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13060, "qty": 283, "cost": 3696534, "path": "pullback", "reason": "눌림목 지정가 13,168 도달", "stop": 10340, "target": 21220, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "12,980 vs 12,871"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,344 / 12,871"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,344 / 13,266"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-10.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -81 · 기 +73 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+73%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.03% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "620,178 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "1.03% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,168 (현재 12,980)"}]}
|
||||
{"ts": "2026-09-04T12:34:21.536359+09:00", "side": "SELL", "code": "068270", "name": "셀트리온", "price": 187800, "qty": 48, "proceeds": 8996822, "entry_price": 196100, "pnl": -417390, "pnl_pct": -4.23, "hold_from": "2026-08-06T09:14:08.213823+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "178,744 (현재 187,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 248,168"}, {"label": "추세(10일선)", "ok": false, "detail": "189,720"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -128 · 기 -47"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-04T12:34:21.902524+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 124600, "qty": 38, "cost": 4735510, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 108235, "target": 173695, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "124,500 vs 84,530"}, {"label": "정배열(5>10)", "ok": true, "detail": "90,260 / 84,530"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "90,260 / 75,663"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+78.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +301 · 기 -97 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.78% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "724,799 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.78% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "84"}]}
|
||||
{"ts": "2026-09-04T13:34:23.898414+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13820, "qty": 267, "cost": 3690493, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10340, "target": 21220, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 13,840)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,220"}, {"label": "추세(10일선)", "ok": true, "detail": "12,957"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -81 · 기 +73"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,837"}]}
|
||||
{"ts": "2026-09-07T15:06:30.232287+09:00", "side": "SELL", "code": "327260", "name": "RF머트리얼즈", "price": 33650, "qty": 141, "proceeds": 4735398, "entry_price": 34252, "pnl": -94876, "pnl_pct": -1.76, "hold_from": "2026-08-24T13:14:17.289893+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "23,791 (현재 33,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 65,634"}, {"label": "추세(10일선)", "ok": false, "detail": "35,015"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -281 · 기 -15"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-07T15:06:30.638671+09:00", "side": "BUY", "code": "000720", "name": "현대건설", "price": 128400, "qty": 37, "cost": 4751513, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 110463, "target": 182212, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "127,900 vs 114,410"}, {"label": "정배열(5>10)", "ok": true, "detail": "114,840 / 114,410"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "114,840 / 113,722"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+24.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +8 · 기 -24 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+30%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.12% (환산) vs 평균 0.58% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 122,600"}, {"label": "거래량 급증", "ok": true, "detail": "1,249,244 (환산) vs 평균 648,608"}, {"label": "회전율 급증", "ok": true, "detail": "1.12% (환산) vs 평균 0.58%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-09-08T09:04:22.326266+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 129800, "qty": 37, "cost": 4803320, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 108235, "target": 173695, "signals": [{"label": "손절선", "ok": true, "detail": "108,235 (현재 129,800)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 173,695"}, {"label": "추세(10일선)", "ok": true, "detail": "85,060"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +257 · 기 -87"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 128,770"}]}
|
||||
{"ts": "2026-09-08T09:42:26.307124+09:00", "side": "BUY", "code": "000720", "name": "현대건설", "price": 133000, "qty": 35, "cost": 4655698, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 110463, "target": 182212, "signals": [{"label": "손절선", "ok": true, "detail": "110,463 (현재 133,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 182,212"}, {"label": "추세(10일선)", "ok": true, "detail": "114,930"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -109 · 기 +175"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 132,920"}]}
|
||||
{"ts": "2026-09-11T11:44:24.674261+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 11930, "qty": 65, "cost": 775566, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10071, "target": 16146, "signals": [{"label": "손절선", "ok": true, "detail": "10,071 (현재 11,980)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 16,146"}, {"label": "추세(10일선)", "ok": true, "detail": "11,366"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +5 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 11,976"}]}
|
||||
{"ts": "2026-09-15T09:00:49.345239+09:00", "side": "SELL", "code": "003550", "name": "LG", "price": 113400, "qty": 83, "proceeds": 9393846, "entry_price": 120431, "pnl": -603453, "pnl_pct": -5.84, "hold_from": "2026-08-14T13:34:15.676304+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "108,115 (현재 111,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 157,380"}, {"label": "추세(10일선)", "ok": false, "detail": "118,280"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -16 · 기 -177"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-15T09:10:30.136504+09:00", "side": "BUY", "code": "316140", "name": "우리금융지주", "price": 36450, "qty": 127, "cost": 4629844, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 32978, "target": 46866, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "36,200 vs 32,230"}, {"label": "정배열(5>10)", "ok": true, "detail": "33,260 / 32,230"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "33,260 / 30,948"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +3,281 · 기 -786 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+21%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.54% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 33,850"}, {"label": "거래량 급증", "ok": true, "detail": "3,898,453 (환산) vs 평균 2,489,243"}, {"label": "회전율 급증", "ok": true, "detail": "0.54% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "72"}]}
|
||||
{"ts": "2026-09-22T09:32:35.251487+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 109700, "qty": 75, "proceeds": 8211456, "entry_price": 127165, "pnl": -1327374, "pnl_pct": -13.73, "hold_from": "2026-09-04T12:34:21.902508+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "110,486 (현재 110,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 177,203"}, {"label": "추세(10일선)", "ok": true, "detail": "83,080"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +266 · 기 -141"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-22T09:32:35.675940+09:00", "side": "BUY", "code": "088350", "name": "한화생명", "price": 5740, "qty": 817, "cost": 4690283, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5129, "target": 7573, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "5,720 vs 4,822"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,161 / 4,822"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,161 / 4,734"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+26.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -544 · 기 +443 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+11%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.68% (환산) vs 평균 0.43% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,630"}, {"label": "거래량 급증", "ok": true, "detail": "5,933,633 (환산) vs 평균 3,731,411"}, {"label": "회전율 급증", "ok": true, "detail": "0.68% (환산) vs 평균 0.43%"}, {"label": "RSI", "ok": true, "detail": "74"}]}
|
||||
{"ts": "2026-10-02T14:48:36.881112+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 304500, "qty": 31, "proceeds": 9421093, "entry_price": 347065, "pnl": -1339521, "pnl_pct": -12.26, "hold_from": "2026-07-16T09:04:02.184863+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "305,213 (현재 305,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 472,619"}, {"label": "추세(10일선)", "ok": true, "detail": "301,850"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +84 · 기 -122"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-02T14:48:37.320243+09:00", "side": "BUY", "code": "161890", "name": "한국콜마", "price": 136600, "qty": 34, "cost": 4645097, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 116526, "target": 196823, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "136,300 vs 112,690"}, {"label": "정배열(5>10)", "ok": true, "detail": "114,560 / 112,690"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "114,560 / 92,538"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+50.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +32 · 기 -67 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+27%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.28% (환산) vs 평균 1.76% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 124,600"}, {"label": "거래량 급증", "ok": true, "detail": "1,006,968 (환산) vs 평균 415,172"}, {"label": "회전율 급증", "ok": true, "detail": "4.28% (환산) vs 평균 1.76%"}, {"label": "RSI", "ok": true, "detail": "76"}]}
|
||||
{"ts": "2026-10-06T13:48:37.716813+09:00", "side": "SELL", "code": "000720", "name": "현대건설", "price": 112600, "qty": 72, "proceeds": 8091391, "entry_price": 130636, "pnl": -1315820, "pnl_pct": -13.81, "hold_from": "2026-09-07T15:06:30.638654+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "112,556 (현재 112,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 184,877"}, {"label": "추세(10일선)", "ok": false, "detail": "112,890"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -101 · 기 -533"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-06T13:48:38.139374+09:00", "side": "BUY", "code": "108490", "name": "로보티즈", "price": 316500, "qty": 14, "cost": 4431665, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 268844, "target": 459469, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "316,000 vs 263,000"}, {"label": "정배열(5>10)", "ok": true, "detail": "274,000 / 263,000"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "274,000 / 242,227"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+22.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +82 · 기 +24 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+24%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.91% (환산) vs 평균 1.94% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 315,000"}, {"label": "거래량 급증", "ok": true, "detail": "574,160 (환산) vs 평균 284,061"}, {"label": "회전율 급증", "ok": true, "detail": "3.91% (환산) vs 평균 1.94%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-10-08T09:06:38.026073+09:00", "side": "SELL", "code": "316140", "name": "우리금융지주", "price": 32950, "qty": 127, "proceeds": 4176490, "entry_price": 36450, "pnl": -453354, "pnl_pct": -9.6, "hold_from": "2026-09-15T09:10:30.136492+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "32,978 (현재 32,950)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 46,866"}, {"label": "추세(10일선)", "ok": true, "detail": "31,905"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +17,844 · 기 +1,402"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 37,223"}]}
|
||||
{"ts": "2026-10-08T09:06:38.453290+09:00", "side": "BUY", "code": "295310", "name": "에이치브이엠", "price": 70400, "qty": 65, "cost": 4576686, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 61192, "target": 98025, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "70,700 vs 55,755"}, {"label": "정배열(5>10)", "ok": true, "detail": "61,380 / 55,755"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "61,380 / 47,905"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+42.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -73 · 기 +228 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+19%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.50% (환산) vs 평균 0.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 61,900"}, {"label": "거래량 급증", "ok": true, "detail": "185,780 (환산) vs 평균 120,414"}, {"label": "회전율 급증", "ok": true, "detail": "1.50% (환산) vs 평균 0.97%"}, {"label": "RSI", "ok": true, "detail": "75"}]}
|
||||
{"ts": "2026-10-08T09:20:39.679141+09:00", "side": "BUY", "code": "295310", "name": "에이치브이엠", "price": 73700, "qty": 62, "cost": 4570085, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 61192, "target": 98025, "signals": [{"label": "손절선", "ok": true, "detail": "61,192 (현재 73,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 98,025"}, {"label": "추세(10일선)", "ok": true, "detail": "56,025"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -73 · 기 +228"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 72,709"}]}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.943289+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.148135+09:00"
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944222+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.276302+09:00"
|
||||
}
|
||||
@@ -1,13 +1,204 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"cash": 39593090.37300002,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944232+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.277525+09:00"
|
||||
"positions": {
|
||||
"194370": {
|
||||
"code": "194370",
|
||||
"name": "제이에스코퍼레이션",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 532,
|
||||
"entry_price": 13160.206766917294,
|
||||
"entry_at": "2026-08-03T12:00:11.186392+09:00",
|
||||
"stop": 11445,
|
||||
"target": 15733,
|
||||
"peak": 14150,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 12200,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3504336.4241500003,
|
||||
"last_add_price": 13420
|
||||
},
|
||||
"041520": {
|
||||
"code": "041520",
|
||||
"name": "이엘씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 566,
|
||||
"entry_price": 11757.597173144877,
|
||||
"entry_at": "2026-09-03T09:02:24.974496+09:00",
|
||||
"stop": 10215,
|
||||
"target": 14072,
|
||||
"peak": 13200,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 11,601 도달",
|
||||
"cur_price": 12790,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3334430.645615085,
|
||||
"last_add_price": 11930
|
||||
},
|
||||
"316140": {
|
||||
"code": "316140",
|
||||
"name": "우리금융지주",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 265,
|
||||
"entry_price": 35694.90566037736,
|
||||
"entry_at": "2026-09-03T09:30:28.212011+09:00",
|
||||
"stop": 32337,
|
||||
"target": 40732,
|
||||
"peak": 36900,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 32500,
|
||||
"tranches": 2,
|
||||
"tranche_value": 4741640.48120924,
|
||||
"last_add_price": 36150
|
||||
},
|
||||
"252990": {
|
||||
"code": "252990",
|
||||
"name": "샘씨엔에스",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 304,
|
||||
"entry_price": 13496.184210526315,
|
||||
"entry_at": "2026-09-07T15:06:35.044589+09:00",
|
||||
"stop": 18268,
|
||||
"target": 18110,
|
||||
"peak": 20700,
|
||||
"trailing_on": true,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 13,160 도달",
|
||||
"cur_price": 18340,
|
||||
"tranches": 2,
|
||||
"tranche_value": 2059070.58259455,
|
||||
"last_add_price": 13930
|
||||
},
|
||||
"004370": {
|
||||
"code": "004370",
|
||||
"name": "농심",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 12,
|
||||
"entry_price": 406000,
|
||||
"entry_at": "2026-10-01T09:02:45.301394+09:00",
|
||||
"stop": 372500,
|
||||
"target": 456250,
|
||||
"peak": 410000,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 410,925 도달",
|
||||
"cur_price": 398000,
|
||||
"tranches": 1,
|
||||
"tranche_value": 5205421.720847539,
|
||||
"last_add_price": 406000
|
||||
},
|
||||
"093370": {
|
||||
"code": "093370",
|
||||
"name": "후성",
|
||||
"sources": [
|
||||
"interest"
|
||||
],
|
||||
"qty": 412,
|
||||
"entry_price": 15444.368932038835,
|
||||
"entry_at": "2026-10-01T09:12:47.860612+09:00",
|
||||
"stop": 13324,
|
||||
"target": 18625,
|
||||
"peak": 16450,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 15150,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3193742.1412302153,
|
||||
"last_add_price": 15740
|
||||
},
|
||||
"122640": {
|
||||
"code": "122640",
|
||||
"name": "예스티",
|
||||
"sources": [
|
||||
"interest"
|
||||
],
|
||||
"qty": 90,
|
||||
"entry_price": 34500,
|
||||
"entry_at": "2026-10-01T10:38:41.833335+09:00",
|
||||
"stop": 29749,
|
||||
"target": 41627,
|
||||
"peak": 34750,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 32400,
|
||||
"tranches": 1,
|
||||
"tranche_value": 3118790.5134803206,
|
||||
"last_add_price": 34500
|
||||
},
|
||||
"456010": {
|
||||
"code": "456010",
|
||||
"name": "아이씨티케이",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 122,
|
||||
"entry_price": 24250,
|
||||
"entry_at": "2026-10-08T09:02:53.389847+09:00",
|
||||
"stop": 20800,
|
||||
"target": 29425,
|
||||
"peak": 24650,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 24,970 도달",
|
||||
"cur_price": 24100,
|
||||
"tranches": 1,
|
||||
"tranche_value": 2973963.562447827,
|
||||
"last_add_price": 24250
|
||||
}
|
||||
},
|
||||
"realized_pnl": -15883632.137000024,
|
||||
"closed_trades": 41,
|
||||
"wins": 4,
|
||||
"peak_equity": 100172467.23000002,
|
||||
"max_drawdown": 0.1585967411636447,
|
||||
"last_exit": {
|
||||
"030000": "2026-09-04",
|
||||
"086790": "2026-07-28",
|
||||
"403870": "2026-07-07",
|
||||
"240810": "2026-07-24",
|
||||
"214450": "2026-09-10",
|
||||
"093370": "2026-07-20",
|
||||
"055550": "2026-07-20",
|
||||
"024060": "2026-07-27",
|
||||
"003680": "2026-08-24",
|
||||
"138040": "2026-08-19",
|
||||
"010950": "2026-09-03",
|
||||
"252990": "2026-09-01",
|
||||
"090850": "2026-08-05",
|
||||
"078930": "2026-09-03",
|
||||
"068270": "2026-09-04",
|
||||
"327260": "2026-09-07",
|
||||
"004370": "2026-09-21",
|
||||
"005930": "2026-10-01",
|
||||
"088350": "2026-10-01",
|
||||
"001790": "2026-10-01",
|
||||
"079650": "2026-10-07"
|
||||
},
|
||||
"created_at": "2026-06-11T09:00:01.944232+09:00",
|
||||
"updated_at": "2026-10-08T15:28:47.138392+09:00"
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{"ts": "2026-06-17T11:15:02.299761+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 130900, "qty": 38, "cost": 4974946, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 118039, "target": 150191, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "129,300 vs 118,950"}, {"label": "정배열(5>10)", "ok": true, "detail": "120,400 / 118,950"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "120,400 / 118,307"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-13.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +371 · 기 +158 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+22%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.12% (환산) vs 평균 0.38% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 126,500"}, {"label": "거래량 급증", "ok": true, "detail": "3,094,534 (환산) vs 평균 1,031,725"}, {"label": "회전율 급증", "ok": true, "detail": "1.12% (환산) vs 평균 0.38%"}, {"label": "RSI", "ok": true, "detail": "60"}]}
|
||||
{"ts": "2026-06-19T09:02:06.341563+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19160, "qty": 325, "cost": 6227934, "path": "pullback", "reason": "눌림목 지정가 19,690 도달", "stop": 17760, "target": 21260, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,150 vs 18,963"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,616 / 18,963"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,616 / 19,361"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-31.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,162 · 기 +2,080 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "37,673 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,690 (현재 19,150)"}]}
|
||||
{"ts": "2026-06-23T09:00:09.238138+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18820, "qty": 325, "proceeds": 6104573, "entry_price": 19160, "pnl": -123361, "pnl_pct": -1.77, "hold_from": "2026-06-19T09:02:06.341561+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,710)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,260"}, {"label": "추세(10일선)", "ok": false, "detail": "18,919"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -318 · 기 -227"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,528"}]}
|
||||
{"ts": "2026-06-23T12:04:04.077319+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 117700, "qty": 38, "proceeds": 4463878, "entry_price": 130900, "pnl": -511068, "pnl_pct": -10.08, "hold_from": "2026-06-17T11:15:02.299760+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "118,039 (현재 117,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 150,191"}, {"label": "추세(10일선)", "ok": false, "detail": "119,330"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +332 · 기 -364"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 134,300"}]}
|
||||
{"ts": "2026-06-24T09:02:02.369304+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 53200, "qty": 49, "cost": 2607191, "path": "pullback", "reason": "눌림목 지정가 60,114 도달", "stop": 43200, "target": 68200, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "53,200 vs 52,550"}, {"label": "정배열(5>10)", "ok": true, "detail": "57,960 / 52,550"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "57,960 / 49,671"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +711 · 기 -2,060 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+29%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.34% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "1,102,733 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "1.34% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 60,114 (현재 53,200)"}]}
|
||||
{"ts": "2026-06-24T09:08:00.981981+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 155400, "qty": 13, "cost": 2020503, "path": "pullback", "reason": "눌림목 지정가 155,963 도달", "stop": 118951, "target": 210074, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "155,400 vs 128,090"}, {"label": "정배열(5>10)", "ok": true, "detail": "143,100 / 128,090"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "143,100 / 121,690"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+45.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +143 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+6%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "6.50% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "3,192,593 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "6.50% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 155,963 (현재 155,400)"}]}
|
||||
{"ts": "2026-06-26T09:00:05.483093+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 50000, "qty": 49, "proceeds": 2445222, "entry_price": 53200, "pnl": -161969, "pnl_pct": -6.02, "hold_from": "2026-06-24T09:02:02.369303+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "43,200 (현재 51,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 68,200"}, {"label": "추세(10일선)", "ok": false, "detail": "52,330"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -311 · 기 -267"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 57,056"}]}
|
||||
{"ts": "2026-06-26T09:04:03.892266+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 164900, "qty": 12, "cost": 1979097, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 118951, "target": 210074, "signals": [{"label": "손절선", "ok": true, "detail": "118,951 (현재 164,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 210,074"}, {"label": "추세(10일선)", "ok": true, "detail": "129,020"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -140 · 기 +323"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 164,359"}]}
|
||||
{"ts": "2026-06-30T09:00:05.439867+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 55300, "qty": 40, "cost": 2212332, "path": "pullback", "reason": "눌림목 지정가 60,222 도달", "stop": 43200, "target": 73450, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "53,700 vs 52,600"}, {"label": "정배열(5>10)", "ok": true, "detail": "58,060 / 52,600"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "58,060 / 49,679"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +222 · 기 -821 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+28%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "827 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 60,222 (현재 53,700)"}]}
|
||||
{"ts": "2026-07-01T09:06:03.204343+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 357500, "qty": 11, "cost": 3933090, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 314220, "target": 422420, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "357,000 vs 307,050"}, {"label": "정배열(5>10)", "ok": true, "detail": "317,600 / 307,050"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "317,600 / 305,700"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -75 · 기 +221 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+35%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.89% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "196,247 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.89% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "68"}]}
|
||||
{"ts": "2026-07-01T09:24:02.305227+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 372000, "qty": 11, "cost": 4092614, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 314220, "target": 422420, "signals": [{"label": "손절선", "ok": true, "detail": "314,220 (현재 372,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 422,420"}, {"label": "추세(10일선)", "ok": true, "detail": "308,550"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -75 · 기 +221"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 368,499"}]}
|
||||
{"ts": "2026-07-02T10:18:03.823440+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 15980, "qty": 139, "cost": 2221553, "path": "pullback", "reason": "눌림목 지정가 16,358 도달", "stop": 12444, "target": 21284, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "15,980 vs 13,620"}, {"label": "정배열(5>10)", "ok": true, "detail": "16,104 / 13,620"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "16,104 / 11,478"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+25.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,833 · 기 +437 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+25%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "9.98% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": true, "detail": "10,701,165 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": true, "detail": "9.98% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 16,358 (현재 15,980)"}]}
|
||||
{"ts": "2026-07-02T10:40:02.946612+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18960, "qty": 324, "cost": 6143961, "path": "pullback", "reason": "눌림목 지정가 19,580 도달", "stop": 17760, "target": 20760, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,960 vs 18,944"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,578 / 18,944"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,578 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -339 · 기 +110 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.86% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "986,112 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.86% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,580 (현재 18,960)"}]}
|
||||
{"ts": "2026-07-02T11:20:02.049404+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 22550, "qty": 273, "cost": 6157073, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 20760, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 19,350)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,760"}, {"label": "추세(10일선)", "ok": true, "detail": "18,983"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,350"}]}
|
||||
{"ts": "2026-07-02T11:24:06.141326+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18860, "qty": 597, "proceeds": 11237464, "entry_price": 20602, "pnl": -1063571, "pnl_pct": -8.45, "hold_from": "2026-07-02T10:40:02.946610+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "19,042 (현재 18,890)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,942"}, {"label": "추세(10일선)", "ok": false, "detail": "18,937"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T09:44:02.602967+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 123600, "qty": 25, "proceeds": 3083974, "entry_price": 159960, "pnl": -915625, "pnl_pct": -22.73, "hold_from": "2026-06-24T09:08:00.981980+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "124,125 (현재 123,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 213,713"}, {"label": "추세(10일선)", "ok": false, "detail": "124,920"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +674 · 기 -2,170"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T10:24:02.766124+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19040, "qty": 315, "cost": 5998500, "path": "pullback", "reason": "눌림목 지정가 19,631 도달", "stop": 17760, "target": 20960, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,030 vs 18,951"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,592 / 18,951"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,592 / 19,359"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -35 · 기 +168 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.54% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "619,193 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.54% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,631 (현재 19,030)"}]}
|
||||
{"ts": "2026-07-06T09:00:07.944661+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 130600, "qty": 13, "cost": 1698055, "path": "pullback", "reason": "눌림목 지정가 154,452 도달", "stop": 96000, "target": 182500, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "133,100 vs 125,860"}, {"label": "정배열(5>10)", "ok": true, "detail": "138,640 / 125,860"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "138,640 / 121,318"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +782 · 기 -1,979 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+31%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.01% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": false, "detail": "4,080 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": false, "detail": "0.01% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 154,452 (현재 133,100)"}]}
|
||||
{"ts": "2026-07-06T09:46:01.641444+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 320500, "qty": 22, "proceeds": 7037251, "entry_price": 364750, "pnl": -988453, "pnl_pct": -12.13, "hold_from": "2026-07-01T09:06:03.204341+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "320,756 (현재 320,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 430,741"}, {"label": "추세(10일선)", "ok": true, "detail": "303,400"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -111 · 기 +227"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-07T09:48:07.376376+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 43000, "qty": 40, "proceeds": 1716646, "entry_price": 55300, "pnl": -495686, "pnl_pct": -22.24, "hold_from": "2026-06-30T09:00:05.439858+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "43,200 (현재 43,050)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 73,450"}, {"label": "추세(10일선)", "ok": false, "detail": "51,535"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +1,424 · 기 -5,706"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 59,449"}]}
|
||||
{"ts": "2026-07-07T10:28:06.455126+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 12410, "qty": 139, "proceeds": 1721626, "entry_price": 15980, "pnl": -499927, "pnl_pct": -22.34, "hold_from": "2026-07-02T10:18:03.823437+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "12,444 (현재 12,430)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,284"}, {"label": "추세(10일선)", "ok": false, "detail": "13,265"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,543 · 기 +15"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 16,975"}]}
|
||||
{"ts": "2026-07-07T14:08:19.509937+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 20750, "qty": 289, "cost": 5997650, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 20960, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,960"}, {"label": "추세(10일선)", "ok": true, "detail": "18,901"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 +113"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,358"}]}
|
||||
{"ts": "2026-07-08T09:00:16.934111+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 108300, "qty": 13, "proceeds": 1405155, "entry_price": 130600, "pnl": -292900, "pnl_pct": -17.08, "hold_from": "2026-07-06T09:00:07.944654+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "166,450 (현재 112,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": false, "detail": "123,820"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +243 · 기 -857"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-09T09:36:03.447933+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 132000, "qty": 31, "cost": 4092614, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 117015, "target": 154477, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "131,900 vs 108,660"}, {"label": "정배열(5>10)", "ok": true, "detail": "115,560 / 108,660"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,560 / 113,228"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+22.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -184 · 기 +964 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.91% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "1,026,867 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.91% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-10T11:06:06.461063+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 13660, "qty": 143, "cost": 1953673, "path": "pullback", "reason": "눌림목 지정가 15,971 도달", "stop": 10360, "target": 18610, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,630 vs 13,385"}, {"label": "정배열(5>10)", "ok": true, "detail": "15,634 / 13,385"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,634 / 11,439"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +239 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+47%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "14.23% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": true, "detail": "15,267,605 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": true, "detail": "14.23% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 15,971 (현재 13,630)"}]}
|
||||
{"ts": "2026-07-10T12:14:02.662405+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 126200, "qty": 15, "cost": 1893284, "path": "pullback", "reason": "눌림목 지정가 151,752 도달", "stop": 96000, "target": 171500, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "126,200 vs 125,170"}, {"label": "정배열(5>10)", "ok": true, "detail": "137,260 / 125,170"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "137,260 / 121,203"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +483 · 기 -241 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "7.53% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "3,695,762 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "7.53% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 151,752 (현재 126,200)"}]}
|
||||
{"ts": "2026-07-13T09:00:09.684184+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 136900, "qty": 30, "cost": 4107616, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 117015, "target": 154477, "signals": [{"label": "손절선", "ok": true, "detail": "117,015 (현재 136,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,477"}, {"label": "추세(10일선)", "ok": true, "detail": "109,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -64 · 기 +919"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,864"}]}
|
||||
{"ts": "2026-07-13T09:12:04.289323+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 341500, "qty": 11, "cost": 3757063, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 300363, "target": 403206, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "341,000 vs 305,450"}, {"label": "정배열(5>10)", "ok": true, "detail": "314,400 / 305,450"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "314,400 / 305,433"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -63 · 기 +144 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.90% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "197,687 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.90% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "64"}]}
|
||||
{"ts": "2026-07-13T09:26:03.401224+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 139100, "qty": 14, "cost": 1947692, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 96000, "target": 171500, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 139,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 171,500"}, {"label": "추세(10일선)", "ok": true, "detail": "126,460"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -213 · 기 +772"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 136,162"}]}
|
||||
{"ts": "2026-07-13T09:48:02.326780+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 135500, "qty": 36, "cost": 4878732, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 122472, "target": 155042, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "135,500 vs 121,110"}, {"label": "정배열(5>10)", "ok": true, "detail": "123,700 / 121,110"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "123,700 / 118,657"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -277 · 기 +460 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+18%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.77% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": true, "detail": "2,103,900 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": true, "detail": "0.77% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "65"}]}
|
||||
{"ts": "2026-07-13T10:04:06.402387+09:00", "side": "BUY", "code": "055550", "name": "신한지주", "price": 113100, "qty": 42, "cost": 4750913, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 101931, "target": 129854, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "113,050 vs 100,265"}, {"label": "정배열(5>10)", "ok": true, "detail": "101,790 / 100,265"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "101,790 / 96,676"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+31.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -368 · 기 +1,005 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.71% (환산) vs 평균 0.35% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 109,800"}, {"label": "거래량 급증", "ok": true, "detail": "3,366,381 (환산) vs 평균 1,653,581"}, {"label": "회전율 급증", "ok": true, "detail": "0.71% (환산) vs 평균 0.35%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-15T11:56:05.674537+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 138900, "qty": 35, "cost": 4862229, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 122472, "target": 155042, "signals": [{"label": "손절선", "ok": true, "detail": "122,472 (현재 138,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 155,042"}, {"label": "추세(10일선)", "ok": true, "detail": "121,450"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -40 · 기 +527"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 138,828"}]}
|
||||
{"ts": "2026-07-15T14:32:01.874159+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18570, "qty": 604, "proceeds": 11194408, "entry_price": 19858, "pnl": -801741, "pnl_pct": -6.49, "hold_from": "2026-07-03T10:24:02.766122+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "18,586 (현재 18,570)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,767"}, {"label": "추세(10일선)", "ok": false, "detail": "18,905"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -214 · 기 +240"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-16T09:28:03.472538+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 353000, "qty": 11, "cost": 3883582, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 300363, "target": 403206, "signals": [{"label": "손절선", "ok": true, "detail": "300,363 (현재 352,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 403,206"}, {"label": "추세(10일선)", "ok": true, "detail": "306,600"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -33 · 기 +28"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 351,963"}]}
|
||||
{"ts": "2026-07-20T11:28:01.181290+09:00", "side": "SELL", "code": "055550", "name": "신한지주", "price": 101900, "qty": 42, "proceeds": 4271454, "entry_price": 113100, "pnl": -479458, "pnl_pct": -9.9, "hold_from": "2026-07-13T10:04:06.402386+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "101,931 (현재 101,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 129,854"}, {"label": "추세(10일선)", "ok": true, "detail": "99,150"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -156 · 기 +275"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 115,714"}]}
|
||||
{"ts": "2026-07-20T12:28:02.306152+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 10310, "qty": 143, "proceeds": 1471455, "entry_price": 13660, "pnl": -482218, "pnl_pct": -24.52, "hold_from": "2026-07-10T11:06:06.461061+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "10,360 (현재 10,310)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,610"}, {"label": "추세(10일선)", "ok": false, "detail": "12,013"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -887 · 기 +515"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,626"}]}
|
||||
{"ts": "2026-07-23T09:50:01.505017+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 88200, "qty": 43, "cost": 3793169, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 77564, "target": 104155, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "88,200 vs 80,900"}, {"label": "정배열(5>10)", "ok": true, "detail": "83,000 / 80,900"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,000 / 75,058"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+47.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -150 · 기 +299 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.85% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "785,587 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.85% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-07-23T10:30:02.474411+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 14140, "qty": 190, "cost": 2687003, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11706, "target": 17791, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,180 vs 12,429"}, {"label": "정배열(5>10)", "ok": true, "detail": "13,154 / 12,429"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "13,154 / 12,687"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+55.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +109 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "84.26% (환산) vs 평균 31.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 13,990"}, {"label": "거래량 급증", "ok": true, "detail": "12,639,979 (환산) vs 평균 4,720,532"}, {"label": "회전율 급증", "ok": true, "detail": "84.26% (환산) vs 평균 31.47%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-23T12:54:04.487607+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 90900, "qty": 42, "cost": 3818373, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 77564, "target": 104155, "signals": [{"label": "손절선", "ok": true, "detail": "77,564 (현재 91,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 104,155"}, {"label": "추세(10일선)", "ok": true, "detail": "81,180"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -169 · 기 +311"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 90,945"}]}
|
||||
{"ts": "2026-07-24T09:00:05.421727+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 111800, "qty": 29, "proceeds": 3235878, "entry_price": 132428, "pnl": -605098, "pnl_pct": -15.58, "hold_from": "2026-07-10T12:14:02.662404+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 113,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 187,069"}, {"label": "추세(10일선)", "ok": false, "detail": "123,880"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -480 · 기 -206"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-24T09:02:04.034477+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 15060, "qty": 179, "cost": 2696144, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11706, "target": 17791, "signals": [{"label": "손절선", "ok": true, "detail": "11,706 (현재 15,020)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,791"}, {"label": "추세(10일선)", "ok": true, "detail": "12,513"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +208 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,766"}]}
|
||||
{"ts": "2026-07-24T10:02:04.389259+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18950, "qty": 310, "cost": 5875381, "path": "pullback", "reason": "눌림목 지정가 19,643 도달", "stop": 17760, "target": 20735, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,950 vs 18,943"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,576 / 18,943"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,576 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+19.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +2 · 기 -5 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.39% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "454,069 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.39% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,643 (현재 18,950)"}]}
|
||||
{"ts": "2026-07-27T09:02:06.217883+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7470, "qty": 423, "cost": 3160284, "path": "pullback", "reason": "눌림목 지정가 7,635 도달", "stop": 6370, "target": 9119, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "7,470 vs 5,392"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,452 / 5,392"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,452 / 4,889"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+82.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +49 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.79% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "49,327 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "0.79% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "74"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,635 (현재 7,470)"}]}
|
||||
{"ts": "2026-07-27T09:08:02.078866+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7910, "qty": 400, "cost": 3164475, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 6370, "target": 9119, "signals": [{"label": "손절선", "ok": true, "detail": "6,370 (현재 7,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,119"}, {"label": "추세(10일선)", "ok": true, "detail": "5,434"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +49 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,747"}]}
|
||||
{"ts": "2026-07-27T09:10:01.033059+09:00", "side": "SELL", "code": "024060", "name": "흥구석유", "price": 12030, "qty": 369, "proceeds": 4430414, "entry_price": 14586, "pnl": -952734, "pnl_pct": -17.53, "hold_from": "2026-07-23T10:30:02.474409+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "12,082 (현재 12,030)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,342"}, {"label": "추세(10일선)", "ok": false, "detail": "12,214"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -85 · 기 +2"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-27T09:12:01.497852+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13150, "qty": 164, "cost": 2156923, "path": "pullback", "reason": "눌림목 지정가 13,180 도달", "stop": 10340, "target": 17365, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,150 vs 12,888"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,378 / 12,888"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,378 / 13,268"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+1.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +27 · 기 +15 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.46% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "274,967 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.46% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,180 (현재 13,150)"}]}
|
||||
{"ts": "2026-07-27T09:32:01.372128+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 127900, "qty": 45, "cost": 5756363, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 117645, "target": 143283, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "127,900 vs 117,400"}, {"label": "정배열(5>10)", "ok": true, "detail": "119,040 / 117,400"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "119,040 / 110,150"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+46.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +106 · 기 +291 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+16%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.38% (환산) vs 평균 0.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 123,300"}, {"label": "거래량 급증", "ok": true, "detail": "643,340 (환산) vs 평균 289,522"}, {"label": "회전율 급증", "ok": true, "detail": "0.38% (환산) vs 평균 0.17%"}, {"label": "RSI", "ok": true, "detail": "71"}]}
|
||||
{"ts": "2026-07-28T11:22:00.740360+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 6630, "qty": 823, "proceeds": 5445850, "entry_price": 7684, "pnl": -878909, "pnl_pct": -13.72, "hold_from": "2026-07-27T09:02:06.217881+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "6,577 (현재 6,380)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,344"}, {"label": "추세(10일선)", "ok": true, "detail": "5,282"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +96 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-28T12:56:02.869289+09:00", "side": "SELL", "code": "138040", "name": "메리츠금융지주", "price": 117400, "qty": 45, "proceeds": 5272698, "entry_price": 127900, "pnl": -483665, "pnl_pct": -8.21, "hold_from": "2026-07-27T09:32:01.372126+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "117,645 (현재 117,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 143,283"}, {"label": "추세(10일선)", "ok": true, "detail": "116,360"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +84 · 기 +324"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 130,553"}]}
|
||||
{"ts": "2026-07-28T13:20:03.820316+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 123800, "qty": 71, "proceeds": 8772660, "entry_price": 137176, "pnl": -968301, "pnl_pct": -9.75, "hold_from": "2026-07-13T09:48:02.326778+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "123,862 (현재 123,800)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 157,147"}, {"label": "추세(10일선)", "ok": true, "detail": "119,940"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +122 · 기 -106"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:02:02.215048+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6540, "qty": 357, "cost": 2335130, "path": "pullback", "reason": "눌림목 지정가 7,526 도달", "stop": 5295, "target": 8408, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,540 vs 5,298"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,266 / 5,298"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,266 / 4,873"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+70.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +188 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.87% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "116,047 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "1.87% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "63"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,526 (현재 6,540)"}]}
|
||||
{"ts": "2026-07-29T09:24:08.685814+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 128600, "qty": 61, "proceeds": 7829303, "entry_price": 134410, "pnl": -370927, "pnl_pct": -4.32, "hold_from": "2026-07-09T09:36:03.447931+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "118,954 (현재 116,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 157,594"}, {"label": "추세(10일선)", "ok": true, "detail": "105,620"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +245"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:24:08.686031+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 11310, "qty": 191, "cost": 2160534, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10340, "target": 17365, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 15,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,365"}, {"label": "추세(10일선)", "ok": true, "detail": "12,768"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +242 · 기 -182"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,887"}]}
|
||||
{"ts": "2026-07-29T09:26:01.888464+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18940, "qty": 310, "proceeds": 5859951, "entry_price": 18950, "pnl": -15430, "pnl_pct": -0.05, "hold_from": "2026-07-24T10:02:04.389258+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "19,645 (현재 18,950)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "18,943"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +99 · 기 +138"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:26:01.888676+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 6610, "qty": 357, "proceeds": 2355168, "entry_price": 6540, "pnl": 20038, "pnl_pct": 1.07, "hold_from": "2026-07-29T09:02:02.215046+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "7,526 (현재 6,610)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "5,306"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +188 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:28:01.375105+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6390, "qty": 848, "cost": 5419533, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5867, "target": 7174, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,380 vs 5,591"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,742 / 5,591"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,742 / 5,622"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+43.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +73 · 기 -3 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.62% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "134,413 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.62% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "73"}]}
|
||||
{"ts": "2026-07-29T10:24:01.432673+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 123400, "qty": 42, "cost": 5183577, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 113016, "target": 138976, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "123,400 vs 116,950"}, {"label": "정배열(5>10)", "ok": true, "detail": "118,140 / 116,950"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "118,140 / 110,075"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+51.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +47 · 기 +310 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+20%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.38% (환산) vs 평균 0.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 123,300"}, {"label": "거래량 급증", "ok": true, "detail": "644,676 (환산) vs 평균 289,522"}, {"label": "회전율 급증", "ok": true, "detail": "0.38% (환산) vs 평균 0.17%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-07-29T10:52:02.342370+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 10340, "qty": 355, "proceeds": 3663542, "entry_price": 12160, "pnl": -653915, "pnl_pct": -14.97, "hold_from": "2026-07-27T09:12:01.497849+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "10,340 (현재 10,340)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 14,890"}, {"label": "추세(10일선)", "ok": false, "detail": "12,607"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +239 · 기 -181"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T13:34:03.579933+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5860, "qty": 848, "proceeds": 4959590, "entry_price": 6390, "pnl": -459943, "pnl_pct": -8.29, "hold_from": "2026-07-29T09:28:01.375104+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,867 (현재 5,860)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,174"}, {"label": "추세(10일선)", "ok": true, "detail": "5,539"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +73 · 기 -3"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,525"}]}
|
||||
{"ts": "2026-07-30T09:04:06.858551+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19090, "qty": 288, "cost": 5498745, "path": "pullback", "reason": "눌림목 지정가 19,653 도달", "stop": 17760, "target": 21085, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,100 vs 18,958"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,606 / 18,958"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,606 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+29.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +2 · 기 +327 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+32%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.14% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "160,727 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.14% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,653 (현재 19,100)"}]}
|
||||
{"ts": "2026-07-30T09:04:06.859148+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6130, "qty": 329, "cost": 2017073, "path": "pullback", "reason": "눌림목 지정가 7,457 도달", "stop": 4793, "target": 8135, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,120 vs 5,256"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,182 / 5,256"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,182 / 4,866"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+65.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +234 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.31% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "267,687 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "4.31% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,457 (현재 6,120)"}]}
|
||||
{"ts": "2026-07-30T09:24:06.912152+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 131800, "qty": 29, "cost": 3822773, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 116987, "target": 154020, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "132,000 vs 108,670"}, {"label": "정배열(5>10)", "ok": true, "detail": "115,580 / 108,670"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,580 / 113,230"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+51.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -6 · 기 +178 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+20%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.66% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "738,813 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.66% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-30T09:26:06.863454+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6300, "qty": 867, "cost": 5462919, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5789, "target": 7067, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,280 vs 5,581"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,722 / 5,581"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,722 / 5,620"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+46.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +87 · 기 +6 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.51% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "109,080 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.51% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "71"}]}
|
||||
{"ts": "2026-07-30T09:50:06.667546+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 126400, "qty": 41, "cost": 5183177, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 113016, "target": 138976, "signals": [{"label": "손절선", "ok": true, "detail": "113,016 (현재 126,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 138,976"}, {"label": "추세(10일선)", "ok": true, "detail": "117,230"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +9 · 기 +389"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 126,082"}]}
|
||||
{"ts": "2026-07-31T09:06:06.408818+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6550, "qty": 308, "cost": 2017703, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 4793, "target": 8135, "signals": [{"label": "손절선", "ok": true, "detail": "4,793 (현재 6,640)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,135"}, {"label": "추세(10일선)", "ok": true, "detail": "5,308"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +355 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,453"}]}
|
||||
{"ts": "2026-07-31T09:06:06.408934+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 77700, "qty": 85, "proceeds": 6591621, "entry_price": 89534, "pnl": -1019920, "pnl_pct": -13.22, "hold_from": "2026-07-23T09:50:01.505016+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "78,555 (현재 78,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 106,003"}, {"label": "추세(10일선)", "ok": false, "detail": "79,880"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -179 · 기 +325"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-03T12:00:11.186394+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 12910, "qty": 271, "cost": 3499135, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11314, "target": 15304, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "12,930 vs 12,003"}, {"label": "정배열(5>10)", "ok": true, "detail": "12,138 / 12,003"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "12,138 / 11,924"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +7 · 기 +10 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+74%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.72% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 12,870"}, {"label": "거래량 급증", "ok": true, "detail": "208,994 (환산) vs 평균 99,303"}, {"label": "회전율 급증", "ok": true, "detail": "0.72% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-03T12:02:09.141957+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 13420, "qty": 261, "cost": 3503145, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11314, "target": 15304, "signals": [{"label": "손절선", "ok": true, "detail": "11,314 (현재 13,450)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 15,304"}, {"label": "추세(10일선)", "ok": true, "detail": "12,055"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +7 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,339"}]}
|
||||
{"ts": "2026-08-04T09:50:08.969522+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 90400, "qty": 39, "cost": 3526129, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 79421, "target": 106869, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "90,300 vs 81,110"}, {"label": "정배열(5>10)", "ok": true, "detail": "83,420 / 81,110"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,420 / 75,093"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+56.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +68 · 기 +117 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+14%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.87% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "805,693 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.87% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-08-04T10:32:12.149555+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 93500, "qty": 38, "cost": 3553533, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 79421, "target": 106869, "signals": [{"label": "손절선", "ok": true, "detail": "79,421 (현재 93,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 106,869"}, {"label": "추세(10일선)", "ok": true, "detail": "81,420"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +57 · 기 +117"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 93,241"}]}
|
||||
{"ts": "2026-08-05T09:02:12.579251+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5700, "qty": 867, "proceeds": 4932263, "entry_price": 6300, "pnl": -530656, "pnl_pct": -9.52, "hold_from": "2026-07-30T09:26:06.863452+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,789 (현재 5,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,067"}, {"label": "추세(10일선)", "ok": true, "detail": "5,526"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +14 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,413"}]}
|
||||
{"ts": "2026-08-05T09:02:12.579432+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13040, "qty": 160, "cost": 2086713, "path": "pullback", "reason": "눌림목 지정가 13,170 도달", "stop": 10340, "target": 17090, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,040 vs 12,877"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,356 / 12,877"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,356 / 13,267"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-6.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -72 · 기 +134 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.13% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "78,333 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.13% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,170 (현재 13,040)"}]}
|
||||
{"ts": "2026-08-05T09:38:10.847019+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13830, "qty": 151, "cost": 2088643, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10340, "target": 17090, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 13,860)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,090"}, {"label": "추세(10일선)", "ok": true, "detail": "12,959"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -72 · 기 +134"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,817"}]}
|
||||
{"ts": "2026-08-06T09:00:17.713628+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18730, "qty": 288, "proceeds": 5383721, "entry_price": 19090, "pnl": -115023, "pnl_pct": -1.89, "hold_from": "2026-07-30T09:04:06.858549+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,085"}, {"label": "추세(10일선)", "ok": false, "detail": "18,923"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -203 · 기 -147"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,459"}]}
|
||||
{"ts": "2026-08-06T09:34:15.268992+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 192000, "qty": 26, "cost": 4992749, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 175344, "target": 216984, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "191,800 vs 180,670"}, {"label": "정배열(5>10)", "ok": true, "detail": "186,140 / 180,670"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "186,140 / 176,671"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +255 · 기 -319 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+44%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.67% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 191,700"}, {"label": "거래량 급증", "ok": true, "detail": "1,582,767 (환산) vs 평균 738,783"}, {"label": "회전율 급증", "ok": true, "detail": "0.67% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-07T09:12:15.071471+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 196400, "qty": 25, "cost": 4910736, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 175344, "target": 216984, "signals": [{"label": "손절선", "ok": true, "detail": "175,344 (현재 196,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 216,984"}, {"label": "추세(10일선)", "ok": true, "detail": "181,120"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +338 · 기 +73"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 196,264"}]}
|
||||
{"ts": "2026-08-10T09:00:14.782704+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 136000, "qty": 29, "cost": 3944592, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 116987, "target": 154020, "signals": [{"label": "손절선", "ok": true, "detail": "116,987 (현재 137,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,020"}, {"label": "추세(10일선)", "ok": true, "detail": "109,170"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -253 · 기 +225"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,714"}]}
|
||||
{"ts": "2026-08-12T09:30:11.931330+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 400000, "qty": 22, "proceeds": 8782840, "entry_price": 347250, "pnl": 1142194, "pnl_pct": 15.19, "hold_from": "2026-07-13T09:12:04.289321+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "399,647 (현재 399,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "311,300"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -6 · 기 +72"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-12T09:38:12.218719+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19050, "qty": 296, "cost": 5639646, "path": "pullback", "reason": "눌림목 지정가 19,679 도달", "stop": 17760, "target": 20985, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,040 vs 18,952"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,594 / 18,952"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,594 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +113 · 기 +20 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+32%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.22% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "250,373 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.22% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,679 (현재 19,040)"}]}
|
||||
{"ts": "2026-08-13T09:00:30.556332+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18880, "qty": 296, "proceeds": 5577582, "entry_price": 19050, "pnl": -62063, "pnl_pct": -0.89, "hold_from": "2026-08-12T09:38:12.218718+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,930)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,985"}, {"label": "추세(10일선)", "ok": false, "detail": "18,941"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -50 · 기 -76"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,411"}]}
|
||||
{"ts": "2026-08-13T09:08:16.771869+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 402500, "qty": 8, "cost": 3220483, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 352720, "target": 477170, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "402,000 vs 311,550"}, {"label": "정배열(5>10)", "ok": true, "detail": "326,600 / 311,550"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "326,600 / 306,450"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +27 · 기 +23 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+26%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.20% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "228,347 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "2.20% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "75"}]}
|
||||
{"ts": "2026-08-13T14:02:16.525079+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 415500, "qty": 8, "cost": 3324499, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 352720, "target": 477170, "signals": [{"label": "손절선", "ok": true, "detail": "352,720 (현재 415,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 477,170"}, {"label": "추세(10일선)", "ok": true, "detail": "312,900"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +19"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 415,052"}]}
|
||||
{"ts": "2026-08-19T09:16:16.546128+09:00", "side": "SELL", "code": "138040", "name": "메리츠금융지주", "price": 113700, "qty": 83, "proceeds": 9418698, "entry_price": 124882, "pnl": -948057, "pnl_pct": -8.95, "hold_from": "2026-07-29T10:24:01.432671+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "114,155 (현재 113,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 140,972"}, {"label": "추세(10일선)", "ok": false, "detail": "115,980"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +206 · 기 -264"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-20T09:02:17.441276+09:00", "side": "BUY", "code": "327260", "name": "RF머트리얼즈", "price": 33400, "qty": 43, "cost": 1436415, "path": "pullback", "reason": "눌림목 지정가 33,138 도달", "stop": 23118, "target": 48823, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "33,000 vs 28,622"}, {"label": "정배열(5>10)", "ok": true, "detail": "35,100 / 28,622"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "35,100 / 31,286"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -71 · 기 +7 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+106%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.67% (환산) vs 평균 3.74% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 45,300"}, {"label": "거래량 급증", "ok": false, "detail": "60,673 (환산) vs 평균 409,191"}, {"label": "회전율 급증", "ok": false, "detail": "0.67% (환산) vs 평균 3.74%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 33,138 (현재 33,000)"}]}
|
||||
{"ts": "2026-08-20T09:16:21.637579+09:00", "side": "BUY", "code": "327260", "name": "RF머트리얼즈", "price": 35800, "qty": 40, "cost": 1432215, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 23118, "target": 48823, "signals": [{"label": "손절선", "ok": true, "detail": "23,118 (현재 36,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 48,823"}, {"label": "추세(10일선)", "ok": true, "detail": "28,922"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -71 · 기 +7"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 35,971"}]}
|
||||
{"ts": "2026-08-24T13:12:20.459313+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 5030, "qty": 637, "proceeds": 3197862, "entry_price": 6333, "pnl": -836913, "pnl_pct": -20.58, "hold_from": "2026-07-30T09:04:06.859145+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,039 (현재 5,030)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,274"}, {"label": "추세(10일선)", "ok": false, "detail": "5,148"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -31 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-24T13:14:20.866192+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19000, "qty": 286, "cost": 5434815, "path": "pullback", "reason": "눌림목 지정가 19,655 도달", "stop": 17760, "target": 20860, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,000 vs 18,948"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,586 / 18,948"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,586 / 19,359"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-3.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +102 · 기 -149 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+32%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.29% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "331,827 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.29% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,655 (현재 19,000)"}]}
|
||||
{"ts": "2026-09-01T09:02:25.875329+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 12660, "qty": 311, "proceeds": 3929582, "entry_price": 13424, "pnl": -245774, "pnl_pct": -5.69, "hold_from": "2026-08-05T09:02:12.579429+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 12,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,049"}, {"label": "추세(10일선)", "ok": false, "detail": "12,846"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -53 · 기 -347"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-01T09:32:25.166638+09:00", "side": "BUY", "code": "001790", "name": "대한제당", "price": 2945, "qty": 1897, "cost": 5587503, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 2767, "target": 3211, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "2,935 vs 2,826"}, {"label": "정배열(5>10)", "ok": true, "detail": "2,866 / 2,826"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "2,866 / 2,600"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +75 · 기 +164 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.46% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 2,905"}, {"label": "거래량 급증", "ok": true, "detail": "399,207 (환산) vs 평균 190,371"}, {"label": "회전율 급증", "ok": true, "detail": "0.46% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-09-03T09:00:32.822879+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 119500, "qty": 77, "proceeds": 9183557, "entry_price": 91930, "pnl": 2103895, "pnl_pct": 29.99, "hold_from": "2026-08-04T09:50:08.969520+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "119,430 (현재 119,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "83,990"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 -35"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-03T09:02:24.974500+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 11590, "qty": 287, "cost": 3326829, "path": "pullback", "reason": "눌림목 지정가 11,601 도달", "stop": 10071, "target": 13868, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "11,590 vs 11,327"}, {"label": "정배열(5>10)", "ok": true, "detail": "11,748 / 11,327"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "11,748 / 9,426"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+28.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +17 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 0.30% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 13,450"}, {"label": "거래량 급증", "ok": false, "detail": "7 (환산) vs 평균 36,789"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 0.30%"}, {"label": "RSI", "ok": true, "detail": "62"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 11,601 (현재 11,590)"}]}
|
||||
{"ts": "2026-09-03T09:10:25.790827+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 145600, "qty": 58, "proceeds": 8428333, "entry_price": 133900, "pnl": 660968, "pnl_pct": 8.74, "hold_from": "2026-07-30T09:24:06.912150+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "145,883 (현재 145,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "110,030"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -31 · 기 +240"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-03T09:30:28.212028+09:00", "side": "BUY", "code": "316140", "name": "우리금융지주", "price": 35250, "qty": 134, "cost": 4724209, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 32007, "target": 40115, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "35,200 vs 32,130"}, {"label": "정배열(5>10)", "ok": true, "detail": "33,060 / 32,130"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "33,060 / 30,932"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+14.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +562 · 기 +643 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+24%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.00% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 33,850"}, {"label": "거래량 급증", "ok": true, "detail": "7,197,153 (환산) vs 평균 2,489,243"}, {"label": "회전율 급증", "ok": true, "detail": "1.00% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-09-04T11:14:26.490576+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18930, "qty": 286, "proceeds": 5403423, "entry_price": 19000, "pnl": -31392, "pnl_pct": -0.37, "hold_from": "2026-08-24T13:14:20.866190+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,940)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,860"}, {"label": "추세(10일선)", "ok": false, "detail": "18,942"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -2 · 기 -39"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,363"}]}
|
||||
{"ts": "2026-09-04T12:02:28.348864+09:00", "side": "BUY", "code": "079650", "name": "서산", "price": 6890, "qty": 239, "cost": 1646957, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5081, "target": 9603, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,910 vs 5,072"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,228 / 5,072"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,228 / 4,366"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+61.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +193 · 기 +5 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "46.65% (환산) vs 평균 23.23% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 6,760"}, {"label": "거래량 급증", "ok": true, "detail": "9,328,695 (환산) vs 평균 4,645,030"}, {"label": "회전율 급증", "ok": true, "detail": "46.65% (환산) vs 평균 23.23%"}, {"label": "RSI", "ok": true, "detail": "68"}]}
|
||||
{"ts": "2026-09-04T12:34:27.129200+09:00", "side": "SELL", "code": "068270", "name": "셀트리온", "price": 187800, "qty": 51, "proceeds": 9559123, "entry_price": 194157, "pnl": -344362, "pnl_pct": -3.27, "hold_from": "2026-08-06T09:34:15.268990+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "177,101 (현재 187,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 219,741"}, {"label": "추세(10일선)", "ok": false, "detail": "189,720"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -128 · 기 -47"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-04T12:50:27.780788+09:00", "side": "BUY", "code": "088350", "name": "한화생명", "price": 5820, "qty": 674, "cost": 3923268, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5177, "target": 6784, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "5,810 vs 4,830"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,179 / 4,830"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,179 / 4,736"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+28.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -2,249 · 기 +1,219 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+9%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.09% (환산) vs 평균 0.43% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,630"}, {"label": "거래량 급증", "ok": true, "detail": "9,490,533 (환산) vs 평균 3,731,411"}, {"label": "회전율 급증", "ok": true, "detail": "1.09% (환산) vs 평균 0.43%"}, {"label": "RSI", "ok": true, "detail": "75"}]}
|
||||
{"ts": "2026-09-07T15:06:35.044011+09:00", "side": "SELL", "code": "327260", "name": "RF머트리얼즈", "price": 33650, "qty": 83, "proceeds": 2787504, "entry_price": 34557, "pnl": -81126, "pnl_pct": -2.62, "hold_from": "2026-08-20T09:02:17.441272+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "24,275 (현재 33,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 49,980"}, {"label": "추세(10일선)", "ok": false, "detail": "35,015"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -281 · 기 -15"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-07T15:06:35.044591+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13090, "qty": 157, "cost": 2055438, "path": "pullback", "reason": "눌림목 지정가 13,160 도달", "stop": 10340, "target": 17215, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,100 vs 12,883"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,368 / 12,883"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,368 / 13,268"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-13.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -111 · 기 +407 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+72%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.25% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "754,723 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "1.25% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,160 (현재 13,100)"}]}
|
||||
{"ts": "2026-09-08T09:02:32.216263+09:00", "side": "BUY", "code": "088350", "name": "한화생명", "price": 6010, "qty": 652, "cost": 3919108, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5177, "target": 6784, "signals": [{"label": "손절선", "ok": true, "detail": "5,177 (현재 6,010)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 6,784"}, {"label": "추세(10일선)", "ok": true, "detail": "4,850"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,497 · 기 +2,372"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 5,980"}]}
|
||||
{"ts": "2026-09-08T12:32:31.602963+09:00", "side": "BUY", "code": "079650", "name": "서산", "price": 7390, "qty": 223, "cost": 1648217, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5081, "target": 9603, "signals": [{"label": "손절선", "ok": true, "detail": "5,081 (현재 7,380)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,603"}, {"label": "추세(10일선)", "ok": true, "detail": "5,120"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +103 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,329"}]}
|
||||
{"ts": "2026-09-10T09:06:30.772066+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 356500, "qty": 16, "proceeds": 5692877, "entry_price": 409000, "pnl": -852104, "pnl_pct": -12.84, "hold_from": "2026-08-13T09:08:16.771867+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "358,791 (현재 358,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 484,313"}, {"label": "추세(10일선)", "ok": true, "detail": "307,150"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +106 · 기 -147"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-10T11:38:28.224555+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13930, "qty": 147, "cost": 2048017, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10340, "target": 17215, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 13,910)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,215"}, {"label": "추세(10일선)", "ok": true, "detail": "12,964"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -256 · 기 +80"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,859"}]}
|
||||
{"ts": "2026-09-10T14:02:37.654102+09:00", "side": "BUY", "code": "004370", "name": "농심", "price": 410500, "qty": 12, "cost": 4926739, "path": "pullback", "reason": "눌림목 지정가 411,461 도달", "stop": 375781, "target": 462578, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "410,500 vs 396,800"}, {"label": "정배열(5>10)", "ok": true, "detail": "404,600 / 396,800"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "404,600 / 369,900"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +8 · 기 -29 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.27% (환산) vs 평균 0.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 440,000"}, {"label": "거래량 급증", "ok": false, "detail": "16,224 (환산) vs 평균 31,191"}, {"label": "회전율 급증", "ok": false, "detail": "0.27% (환산) vs 평균 0.51%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 411,461 (현재 410,500)"}]}
|
||||
{"ts": "2026-09-11T11:44:31.402107+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 11930, "qty": 279, "cost": 3328969, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10071, "target": 13868, "signals": [{"label": "손절선", "ok": true, "detail": "10,071 (현재 11,980)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 13,868"}, {"label": "추세(10일선)", "ok": true, "detail": "11,366"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +5 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 11,976"}]}
|
||||
{"ts": "2026-09-14T09:28:34.566240+09:00", "side": "BUY", "code": "316140", "name": "우리금융지주", "price": 36150, "qty": 131, "cost": 4736360, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 32007, "target": 40115, "signals": [{"label": "손절선", "ok": true, "detail": "32,007 (현재 36,150)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 40,115"}, {"label": "추세(10일선)", "ok": true, "detail": "32,225"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +2,045 · 기 -615"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 36,089"}]}
|
||||
{"ts": "2026-09-21T15:08:43.617889+09:00", "side": "SELL", "code": "004370", "name": "농심", "price": 392500, "qty": 12, "proceeds": 4700816, "entry_price": 410500, "pnl": -225923, "pnl_pct": -4.38, "hold_from": "2026-09-10T14:02:37.654100+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "375,781 (현재 392,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 462,578"}, {"label": "추세(10일선)", "ok": false, "detail": "395,000"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -1 · 기 -5"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 419,823"}]}
|
||||
{"ts": "2026-09-22T09:04:44.417679+09:00", "side": "BUY", "code": "005930", "name": "삼성전자", "price": 282500, "qty": 17, "cost": 4803220, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 257943, "target": 319336, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "282,000 vs 262,350"}, {"label": "정배열(5>10)", "ok": true, "detail": "265,300 / 262,350"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "265,300 / 262,833"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,624 · 기 +8,791 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+73%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.60% (환산) vs 평균 0.29% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 279,000"}, {"label": "거래량 급증", "ok": true, "detail": "35,127,313 (환산) vs 평균 16,802,321"}, {"label": "회전율 급증", "ok": true, "detail": "0.60% (환산) vs 평균 0.29%"}, {"label": "RSI", "ok": true, "detail": "60"}]}
|
||||
{"ts": "2026-10-01T09:00:53.825503+09:00", "side": "SELL", "code": "005930", "name": "삼성전자", "price": 266500, "qty": 17, "proceeds": 4521666, "entry_price": 282500, "pnl": -281555, "pnl_pct": -5.66, "hold_from": "2026-09-22T09:04:44.417677+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "257,943 (현재 268,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 319,336"}, {"label": "추세(10일선)", "ok": false, "detail": "269,100"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -6,202 · 기 -2,419"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 288,311"}]}
|
||||
{"ts": "2026-10-01T09:02:45.301402+09:00", "side": "BUY", "code": "004370", "name": "농심", "price": 406000, "qty": 12, "cost": 4872731, "path": "pullback", "reason": "눌림목 지정가 410,925 도달", "stop": 372500, "target": 456250, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "402,000 vs 395,950"}, {"label": "정배열(5>10)", "ok": true, "detail": "402,900 / 395,950"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "402,900 / 369,758"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+15.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +6 · 기 +5 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.06% (환산) vs 평균 0.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 440,000"}, {"label": "거래량 급증", "ok": false, "detail": "3,460 (환산) vs 평균 31,191"}, {"label": "회전율 급증", "ok": false, "detail": "0.06% (환산) vs 평균 0.51%"}, {"label": "RSI", "ok": true, "detail": "57"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 410,925 (현재 402,000)"}]}
|
||||
{"ts": "2026-10-01T09:10:41.764132+09:00", "side": "SELL", "code": "088350", "name": "한화생명", "price": 5280, "qty": 1326, "proceeds": 6987628, "entry_price": 5913, "pnl": -854749, "pnl_pct": -10.71, "hold_from": "2026-09-04T12:50:27.780786+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,274 (현재 5,270)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 6,873"}, {"label": "추세(10일선)", "ok": true, "detail": "4,776"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -609 · 기 -421"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-01T09:12:47.860614+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 15160, "qty": 210, "cost": 3184078, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 13113, "target": 18231, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "15,260 vs 13,101"}, {"label": "정배열(5>10)", "ok": true, "detail": "13,626 / 13,101"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "13,626 / 12,642"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+23.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +746 · 기 -275 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+31%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "6.79% (환산) vs 평균 2.72% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 14,740"}, {"label": "거래량 급증", "ok": true, "detail": "7,284,333 (환산) vs 평균 2,916,070"}, {"label": "회전율 급증", "ok": true, "detail": "6.79% (환산) vs 평균 2.72%"}, {"label": "RSI", "ok": true, "detail": "70"}]}
|
||||
{"ts": "2026-10-01T09:38:41.381489+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 15740, "qty": 202, "cost": 3179957, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 13113, "target": 18231, "signals": [{"label": "손절선", "ok": true, "detail": "13,113 (현재 15,720)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,231"}, {"label": "추세(10일선)", "ok": true, "detail": "13,147"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +746 · 기 -275"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 15,690"}]}
|
||||
{"ts": "2026-10-01T10:38:41.433153+09:00", "side": "SELL", "code": "001790", "name": "대한제당", "price": 2765, "qty": 1897, "proceeds": 5234977, "entry_price": 2945, "pnl": -352526, "pnl_pct": -6.11, "hold_from": "2026-09-01T09:32:25.166635+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "2,767 (현재 2,765)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 3,211"}, {"label": "추세(10일선)", "ok": false, "detail": "2,809"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -67 · 기 +86"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 2,988"}]}
|
||||
{"ts": "2026-10-01T10:38:41.833351+09:00", "side": "BUY", "code": "122640", "name": "예스티", "price": 34500, "qty": 90, "cost": 3105466, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 29749, "target": 41627, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "34,400 vs 30,080"}, {"label": "정배열(5>10)", "ok": true, "detail": "30,150 / 30,080"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "30,150 / 27,595"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+14.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +49 · 기 +1 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+16%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.97% (환산) vs 평균 1.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 32,800"}, {"label": "거래량 급증", "ok": true, "detail": "860,787 (환산) vs 평균 365,437"}, {"label": "회전율 급증", "ok": true, "detail": "3.97% (환산) vs 평균 1.69%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-10-07T14:42:49.079809+09:00", "side": "SELL", "code": "079650", "name": "서산", "price": 5360, "qty": 462, "proceeds": 2471491, "entry_price": 7131, "pnl": -823683, "pnl_pct": -24.84, "hold_from": "2026-09-04T12:02:28.348862+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,375 (현재 5,350)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,765"}, {"label": "추세(10일선)", "ok": true, "detail": "4,916"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -89 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-08T09:02:53.389863+09:00", "side": "BUY", "code": "456010", "name": "아이씨티케이", "price": 24250, "qty": 122, "cost": 2958944, "path": "pullback", "reason": "눌림목 지정가 24,970 도달", "stop": 20800, "target": 29425, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "24,450 vs 24,440"}, {"label": "정배열(5>10)", "ok": true, "detail": "25,180 / 24,440"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "25,180 / 17,336"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+55.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +115 · 기 +6 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+145%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.80% (환산) vs 평균 8.21% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 30,350"}, {"label": "거래량 급증", "ok": false, "detail": "116,753 (환산) vs 평균 1,191,082"}, {"label": "회전율 급증", "ok": false, "detail": "0.80% (환산) vs 평균 8.21%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 24,970 (현재 24,450)"}]}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944241+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.278741+09:00"
|
||||
}
|
||||
@@ -1,13 +1,205 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"cash": 44992945.30450006,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944251+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.279953+09:00"
|
||||
"positions": {
|
||||
"194370": {
|
||||
"code": "194370",
|
||||
"name": "제이에스코퍼레이션",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 530,
|
||||
"entry_price": 13160.188679245282,
|
||||
"entry_at": "2026-08-03T12:00:11.189297+09:00",
|
||||
"stop": 11445,
|
||||
"target": 15732,
|
||||
"peak": 14150,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 12200,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3497500.5799993444,
|
||||
"last_add_price": 13420
|
||||
},
|
||||
"252990": {
|
||||
"code": "252990",
|
||||
"name": "샘씨엔에스",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 272,
|
||||
"entry_price": 14105.33088235294,
|
||||
"entry_at": "2026-09-04T11:14:26.495104+09:00",
|
||||
"stop": 18268,
|
||||
"target": 18636,
|
||||
"peak": 20700,
|
||||
"trailing_on": true,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 13,946 도달",
|
||||
"cur_price": 18340,
|
||||
"tranches": 2,
|
||||
"tranche_value": 1928587.8820308954,
|
||||
"last_add_price": 14660
|
||||
},
|
||||
"041520": {
|
||||
"code": "041520",
|
||||
"name": "이엘씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 562,
|
||||
"entry_price": 11971.530249110321,
|
||||
"entry_at": "2026-09-15T09:04:39.495077+09:00",
|
||||
"stop": 10441,
|
||||
"target": 14267,
|
||||
"peak": 13200,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 11,977 도달",
|
||||
"cur_price": 12790,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3370314.5438456116,
|
||||
"last_add_price": 12170
|
||||
},
|
||||
"095610": {
|
||||
"code": "095610",
|
||||
"name": "테스",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 21,
|
||||
"entry_price": 158552.38095238095,
|
||||
"entry_at": "2026-09-22T09:02:46.938295+09:00",
|
||||
"stop": 121395,
|
||||
"target": 214288,
|
||||
"peak": 187400,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 157,986 도달",
|
||||
"cur_price": 164200,
|
||||
"tranches": 2,
|
||||
"tranche_value": 1773948.7536711907,
|
||||
"last_add_price": 164000
|
||||
},
|
||||
"372170": {
|
||||
"code": "372170",
|
||||
"name": "윤성에프앤씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 213,
|
||||
"entry_price": 20702.816901408452,
|
||||
"entry_at": "2026-09-23T12:32:47.146539+09:00",
|
||||
"stop": 16947,
|
||||
"target": 26336,
|
||||
"peak": 25500,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 21,336 도달",
|
||||
"cur_price": 23800,
|
||||
"tranches": 2,
|
||||
"tranche_value": 2212317.899773747,
|
||||
"last_add_price": 21400
|
||||
},
|
||||
"025860": {
|
||||
"code": "025860",
|
||||
"name": "남해화학",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 895,
|
||||
"entry_price": 6050,
|
||||
"entry_at": "2026-09-28T09:04:45.106987+09:00",
|
||||
"stop": 5730,
|
||||
"target": 6530,
|
||||
"peak": 6180,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 6,148 도달",
|
||||
"cur_price": 5940,
|
||||
"tranches": 1,
|
||||
"tranche_value": 5419734.413812503,
|
||||
"last_add_price": 6050
|
||||
},
|
||||
"051900": {
|
||||
"code": "051900",
|
||||
"name": "LG생활건강",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 14,
|
||||
"entry_price": 271500,
|
||||
"entry_at": "2026-10-01T09:02:45.598918+09:00",
|
||||
"stop": 242017,
|
||||
"target": 315725,
|
||||
"peak": 277000,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 277,258 도달",
|
||||
"cur_price": 269500,
|
||||
"tranches": 1,
|
||||
"tranche_value": 3971189.5843109195,
|
||||
"last_add_price": 271500
|
||||
},
|
||||
"056360": {
|
||||
"code": "056360",
|
||||
"name": "코위버",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 731,
|
||||
"entry_price": 6360,
|
||||
"entry_at": "2026-10-08T09:50:45.349011+09:00",
|
||||
"stop": 5770,
|
||||
"target": 7245,
|
||||
"peak": 6390,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 6,523 도달",
|
||||
"cur_price": 6210,
|
||||
"tranches": 1,
|
||||
"tranche_value": 4653764.676570003,
|
||||
"last_add_price": 6360
|
||||
}
|
||||
},
|
||||
"realized_pnl": -15857423.131500036,
|
||||
"closed_trades": 45,
|
||||
"wins": 7,
|
||||
"peak_equity": 101529111.72700004,
|
||||
"max_drawdown": 0.16624243648348033,
|
||||
"last_exit": {
|
||||
"030000": "2026-09-04",
|
||||
"086790": "2026-09-23",
|
||||
"403870": "2026-07-07",
|
||||
"095610": "2026-07-29",
|
||||
"093370": "2026-07-20",
|
||||
"240810": "2026-07-24",
|
||||
"214450": "2026-09-10",
|
||||
"034730": "2026-07-07",
|
||||
"055550": "2026-07-20",
|
||||
"024060": "2026-07-27",
|
||||
"003680": "2026-08-24",
|
||||
"252990": "2026-09-01",
|
||||
"010950": "2026-09-03",
|
||||
"090850": "2026-08-05",
|
||||
"078930": "2026-07-31",
|
||||
"068270": "2026-09-04",
|
||||
"138040": "2026-09-08",
|
||||
"003550": "2026-09-15",
|
||||
"004370": "2026-09-21",
|
||||
"000720": "2026-09-28",
|
||||
"316140": "2026-10-01",
|
||||
"079650": "2026-10-08"
|
||||
},
|
||||
"created_at": "2026-06-11T09:00:01.944251+09:00",
|
||||
"updated_at": "2026-10-08T15:28:47.141332+09:00"
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
{"ts": "2026-06-17T11:15:02.304318+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 130900, "qty": 38, "cost": 4974946, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 118039, "target": 150191, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "129,300 vs 118,950"}, {"label": "정배열(5>10)", "ok": true, "detail": "120,400 / 118,950"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "120,400 / 118,307"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-13.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +371 · 기 +158 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+22%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.12% (환산) vs 평균 0.38% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 126,500"}, {"label": "거래량 급증", "ok": true, "detail": "3,094,534 (환산) vs 평균 1,031,725"}, {"label": "회전율 급증", "ok": true, "detail": "1.12% (환산) vs 평균 0.38%"}, {"label": "RSI", "ok": true, "detail": "60"}]}
|
||||
{"ts": "2026-06-17T16:47:28.483754+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 62900, "qty": 35, "cost": 2201830, "path": "pullback", "reason": "눌림목 지정가 64,531 도달", "stop": 48962, "target": 83807, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "62,800 vs 53,510"}, {"label": "정배열(5>10)", "ok": true, "detail": "59,880 / 53,510"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "59,880 / 49,831"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -4,434 · 기 +5,040 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+9%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "11.31% vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "9,309,211 vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": true, "detail": "11.31% vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "58"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 64,531 (현재 62,800)"}]}
|
||||
{"ts": "2026-06-19T09:02:06.344845+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19160, "qty": 325, "cost": 6227934, "path": "pullback", "reason": "눌림목 지정가 20,044 도달", "stop": 17760, "target": 21260, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,150 vs 18,963"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,616 / 18,963"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,616 / 19,361"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-31.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,162 · 기 +2,080 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "37,673 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,044 (현재 19,150)"}]}
|
||||
{"ts": "2026-06-23T09:00:09.342852+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18820, "qty": 325, "proceeds": 6104573, "entry_price": 19160, "pnl": -123361, "pnl_pct": -1.77, "hold_from": "2026-06-19T09:02:06.344843+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,710)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,260"}, {"label": "추세(10일선)", "ok": false, "detail": "18,919"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -318 · 기 -227"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,528"}]}
|
||||
{"ts": "2026-06-23T12:04:04.079542+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 117700, "qty": 38, "proceeds": 4463878, "entry_price": 130900, "pnl": -511068, "pnl_pct": -10.08, "hold_from": "2026-06-17T11:15:02.304317+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "118,039 (현재 117,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 150,191"}, {"label": "추세(10일선)", "ok": false, "detail": "119,330"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +332 · 기 -364"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 134,300"}]}
|
||||
{"ts": "2026-06-24T09:04:06.111950+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 156700, "qty": 13, "cost": 2037406, "path": "pullback", "reason": "눌림목 지정가 165,075 도달", "stop": 120251, "target": 211374, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "156,200 vs 128,170"}, {"label": "정배열(5>10)", "ok": true, "detail": "143,260 / 128,170"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "143,260 / 121,703"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+46.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +143 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+6%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "5.83% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "2,860,073 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "5.83% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 165,075 (현재 156,200)"}]}
|
||||
{"ts": "2026-06-24T09:12:05.347423+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 17270, "qty": 150, "cost": 2590889, "path": "pullback", "reason": "눌림목 지정가 17,366 도달", "stop": 13981, "target": 22203, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "17,280 vs 13,750"}, {"label": "정배열(5>10)", "ok": true, "detail": "16,364 / 13,750"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "16,364 / 11,500"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +1,740 · 기 -508 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+16%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.34% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": false, "detail": "2,509,340 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": false, "detail": "2.34% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "65"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 17,366 (현재 17,280)"}]}
|
||||
{"ts": "2026-06-24T09:46:04.779355+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158200, "qty": 13, "cost": 2056908, "path": "pullback", "reason": "눌림목 지정가 158,264 도달", "stop": 120729, "target": 214407, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "158,000 vs 153,380"}, {"label": "정배열(5>10)", "ok": true, "detail": "172,240 / 153,380"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "172,240 / 99,750"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+56.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +41 · 기 +65 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+5%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.25% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "822,940 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "4.25% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,264 (현재 158,000)"}]}
|
||||
{"ts": "2026-06-24T11:12:05.896210+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 18260, "qty": 142, "cost": 2593309, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 13981, "target": 22203, "signals": [{"label": "손절선", "ok": true, "detail": "13,981 (현재 18,250)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,203"}, {"label": "추세(10일선)", "ok": true, "detail": "13,847"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +1,742 · 기 -508"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 18,094"}]}
|
||||
{"ts": "2026-06-25T09:00:23.474580+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 179100, "qty": 11, "cost": 1970396, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 120729, "target": 214407, "signals": [{"label": "손절선", "ok": true, "detail": "120,729 (현재 178,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 214,407"}, {"label": "추세(10일선)", "ok": true, "detail": "155,410"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -56 · 기 +134"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 167,275"}]}
|
||||
{"ts": "2026-06-26T09:00:05.584610+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 50000, "qty": 35, "proceeds": 1746588, "entry_price": 62900, "pnl": -455243, "pnl_pct": -20.51, "hold_from": "2026-06-17T16:47:28.483745+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "48,962 (현재 51,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 83,807"}, {"label": "추세(10일선)", "ok": false, "detail": "52,330"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -311 · 기 -267"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 66,756"}]}
|
||||
{"ts": "2026-06-26T10:14:04.057812+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 166900, "qty": 12, "cost": 2003100, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 120251, "target": 211374, "signals": [{"label": "손절선", "ok": true, "detail": "120,251 (현재 166,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 211,374"}, {"label": "추세(10일선)", "ok": true, "detail": "129,220"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -126 · 기 +320"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 165,659"}]}
|
||||
{"ts": "2026-06-29T11:36:01.262629+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 745000, "qty": 3, "cost": 2235335, "path": "pullback", "reason": "눌림목 지정가 748,914 도달", "stop": 612829, "target": 943257, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "744,000 vs 735,600"}, {"label": "정배열(5>10)", "ok": true, "detail": "766,800 / 735,600"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "766,800 / 530,600"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -194 · 기 +152 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+8%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.35% (환산) vs 평균 0.45% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 883,000"}, {"label": "거래량 급증", "ok": true, "detail": "977,966 (환산) vs 평균 329,863"}, {"label": "회전율 급증", "ok": true, "detail": "1.35% (환산) vs 평균 0.45%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 748,914 (현재 744,000)"}]}
|
||||
{"ts": "2026-06-29T14:18:01.482930+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 778000, "qty": 3, "cost": 2334350, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 612829, "target": 943257, "signals": [{"label": "손절선", "ok": true, "detail": "612,829 (현재 779,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 943,257"}, {"label": "추세(10일선)", "ok": true, "detail": "739,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -200 · 기 +143"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 778,043"}]}
|
||||
{"ts": "2026-06-30T09:00:05.540133+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 55300, "qty": 41, "cost": 2267640, "path": "pullback", "reason": "눌림목 지정가 63,981 도달", "stop": 43200, "target": 73450, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "53,700 vs 52,600"}, {"label": "정배열(5>10)", "ok": true, "detail": "58,060 / 52,600"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "58,060 / 49,679"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +222 · 기 -821 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+28%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "827 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 63,981 (현재 53,700)"}]}
|
||||
{"ts": "2026-07-01T09:06:03.206698+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 357500, "qty": 11, "cost": 3933090, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 314220, "target": 422420, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "357,000 vs 307,050"}, {"label": "정배열(5>10)", "ok": true, "detail": "317,600 / 307,050"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "317,600 / 305,700"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -75 · 기 +221 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+35%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.89% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "196,247 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.89% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "68"}]}
|
||||
{"ts": "2026-07-01T09:24:02.307379+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 372000, "qty": 11, "cost": 4092614, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 314220, "target": 422420, "signals": [{"label": "손절선", "ok": true, "detail": "314,220 (현재 372,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 422,420"}, {"label": "추세(10일선)", "ok": true, "detail": "308,550"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -75 · 기 +221"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 368,499"}]}
|
||||
{"ts": "2026-07-01T11:06:02.349355+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 212000, "qty": 24, "proceeds": 5078078, "entry_price": 167779, "pnl": 1050774, "pnl_pct": 26.36, "hold_from": "2026-06-24T09:46:04.779353+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "213,193 (현재 213,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "158,880"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -229 · 기 +300"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T10:18:03.827422+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 122200, "qty": 43, "cost": 5255388, "path": "pullback", "reason": "눌림목 지정가 122,215 도달", "stop": 111000, "target": 139000, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "122,000 vs 119,760"}, {"label": "정배열(5>10)", "ok": true, "detail": "121,000 / 119,760"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "121,000 / 118,432"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +22 · 기 -44 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+29%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.80% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": true, "detail": "2,199,199 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": true, "detail": "0.80% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 122,215 (현재 122,000)"}]}
|
||||
{"ts": "2026-07-02T10:40:02.948677+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18960, "qty": 324, "cost": 6143961, "path": "pullback", "reason": "눌림목 지정가 19,970 도달", "stop": 17760, "target": 20760, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,960 vs 18,944"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,578 / 18,944"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,578 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -339 · 기 +110 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.86% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "986,112 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.86% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,970 (현재 18,960)"}]}
|
||||
{"ts": "2026-07-02T11:20:02.051721+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 22550, "qty": 273, "cost": 6157073, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 20760, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 19,350)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,760"}, {"label": "추세(10일선)", "ok": true, "detail": "18,983"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,350"}]}
|
||||
{"ts": "2026-07-02T11:24:06.143347+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18860, "qty": 597, "proceeds": 11237464, "entry_price": 20602, "pnl": -1063571, "pnl_pct": -8.45, "hold_from": "2026-07-02T10:40:02.948676+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "19,042 (현재 18,890)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,942"}, {"label": "추세(10일선)", "ok": false, "detail": "18,937"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T09:04:01.665799+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 14370, "qty": 292, "proceeds": 4187858, "entry_price": 17751, "pnl": -996340, "pnl_pct": -19.05, "hold_from": "2026-06-24T09:12:05.347421+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "14,457 (현재 14,390)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,693"}, {"label": "추세(10일선)", "ok": true, "detail": "13,461"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,591 · 기 +253"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T09:28:01.636551+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 125700, "qty": 25, "proceeds": 3136372, "entry_price": 161596, "pnl": -904134, "pnl_pct": -22.21, "hold_from": "2026-06-24T09:04:06.111949+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "125,761 (현재 125,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 215,349"}, {"label": "추세(10일선)", "ok": true, "detail": "125,120"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +674 · 기 -2,170"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T10:16:02.526460+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 126200, "qty": 42, "cost": 5301195, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111000, "target": 139000, "signals": [{"label": "손절선", "ok": true, "detail": "111,000 (현재 126,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 139,000"}, {"label": "추세(10일선)", "ok": true, "detail": "120,180"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -140 · 기 +278"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 125,493"}]}
|
||||
{"ts": "2026-07-03T10:24:02.768194+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19040, "qty": 314, "cost": 5979457, "path": "pullback", "reason": "눌림목 지정가 20,004 도달", "stop": 17760, "target": 20960, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,030 vs 18,951"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,592 / 18,951"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,592 / 19,359"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -35 · 기 +168 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.54% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "619,193 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.54% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,004 (현재 19,030)"}]}
|
||||
{"ts": "2026-07-06T09:00:08.046896+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 130600, "qty": 13, "cost": 1698055, "path": "pullback", "reason": "눌림목 지정가 164,068 도달", "stop": 96000, "target": 182500, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "133,100 vs 125,860"}, {"label": "정배열(5>10)", "ok": true, "detail": "138,640 / 125,860"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "138,640 / 121,318"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +782 · 기 -1,979 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+31%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.01% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": false, "detail": "4,080 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": false, "detail": "0.01% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 164,068 (현재 133,100)"}]}
|
||||
{"ts": "2026-07-06T09:00:08.048351+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 14510, "qty": 130, "cost": 1886583, "path": "pullback", "reason": "눌림목 지정가 17,169 도달", "stop": 10827, "target": 20034, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,490 vs 13,471"}, {"label": "정배열(5>10)", "ok": true, "detail": "15,806 / 13,471"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,806 / 11,453"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+7.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,226 · 기 +134 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.02% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": false, "detail": "20,667 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": false, "detail": "0.02% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "54"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 17,169 (현재 14,490)"}]}
|
||||
{"ts": "2026-07-06T09:46:01.643722+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 320500, "qty": 22, "proceeds": 7037251, "entry_price": 364750, "pnl": -988453, "pnl_pct": -12.13, "hold_from": "2026-07-01T09:06:03.206697+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "320,756 (현재 320,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 430,741"}, {"label": "추세(10일선)", "ok": true, "detail": "303,400"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -111 · 기 +227"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-07T09:12:07.261676+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 155900, "qty": 12, "cost": 1871081, "path": "pullback", "reason": "눌림목 지정가 158,314 도달", "stop": 118529, "target": 211957, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "155,800 vs 153,160"}, {"label": "정배열(5>10)", "ok": true, "detail": "171,800 / 153,160"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,800 / 99,713"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+43.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +80 · 기 -197 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+14%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.33% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "645,567 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "3.33% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,314 (현재 155,800)"}]}
|
||||
{"ts": "2026-07-07T09:48:07.378539+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 43000, "qty": 41, "proceeds": 1759562, "entry_price": 55300, "pnl": -508078, "pnl_pct": -22.24, "hold_from": "2026-06-30T09:00:05.540126+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "43,200 (현재 43,050)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 73,450"}, {"label": "추세(10일선)", "ok": false, "detail": "51,535"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +1,424 · 기 -5,706"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 59,449"}]}
|
||||
{"ts": "2026-07-07T10:30:07.357407+09:00", "side": "SELL", "code": "034730", "name": "SK", "price": 628000, "qty": 6, "proceeds": 3760652, "entry_price": 761500, "pnl": -809033, "pnl_pct": -17.53, "hold_from": "2026-06-29T11:36:01.262627+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "629,329 (현재 628,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 959,757"}, {"label": "추세(10일선)", "ok": false, "detail": "724,000"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -58 · 기 +6"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-07T12:02:08.175480+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 12230, "qty": 130, "proceeds": 1586800, "entry_price": 14510, "pnl": -299783, "pnl_pct": -15.71, "hold_from": "2026-07-06T09:00:08.048341+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "10,827 (현재 12,230)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,034"}, {"label": "추세(10일선)", "ok": false, "detail": "13,245"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -1,558 · 기 -4"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 15,512"}]}
|
||||
{"ts": "2026-07-07T14:08:22.267417+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 177000, "qty": 11, "cost": 1947292, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 118529, "target": 211957, "signals": [{"label": "손절선", "ok": true, "detail": "118,529 (현재 177,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 211,957"}, {"label": "추세(10일선)", "ok": true, "detail": "148,710"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +152 · 기 -281"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 165,142"}]}
|
||||
{"ts": "2026-07-07T14:08:22.269186+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 20750, "qty": 288, "cost": 5976896, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 20960, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,960"}, {"label": "추세(10일선)", "ok": true, "detail": "18,901"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 +113"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,358"}]}
|
||||
{"ts": "2026-07-08T09:00:17.041286+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 108300, "qty": 13, "proceeds": 1405155, "entry_price": 130600, "pnl": -292900, "pnl_pct": -17.08, "hold_from": "2026-07-06T09:00:08.046892+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "166,450 (현재 112,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": false, "detail": "123,820"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +243 · 기 -857"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-09T09:36:03.450093+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 132000, "qty": 30, "cost": 3960594, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 117015, "target": 154477, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "131,900 vs 108,660"}, {"label": "정배열(5>10)", "ok": true, "detail": "115,560 / 108,660"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,560 / 113,228"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+22.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -184 · 기 +964 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.91% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "1,026,867 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.91% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-10T11:06:06.463233+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 13660, "qty": 143, "cost": 1953673, "path": "pullback", "reason": "눌림목 지정가 16,984 도달", "stop": 10360, "target": 18610, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,630 vs 13,385"}, {"label": "정배열(5>10)", "ok": true, "detail": "15,634 / 13,385"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,634 / 11,439"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +239 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+47%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "14.23% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": true, "detail": "15,267,605 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": true, "detail": "14.23% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 16,984 (현재 13,630)"}]}
|
||||
{"ts": "2026-07-10T12:14:02.664485+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 126200, "qty": 15, "cost": 1893284, "path": "pullback", "reason": "눌림목 지정가 162,268 도달", "stop": 96000, "target": 171500, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "126,200 vs 125,170"}, {"label": "정배열(5>10)", "ok": true, "detail": "137,260 / 125,170"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "137,260 / 121,203"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +483 · 기 -241 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "7.53% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "3,695,762 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "7.53% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 162,268 (현재 126,200)"}]}
|
||||
{"ts": "2026-07-13T09:00:09.784350+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 136900, "qty": 29, "cost": 3970696, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 117015, "target": 154477, "signals": [{"label": "손절선", "ok": true, "detail": "117,015 (현재 136,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,477"}, {"label": "추세(10일선)", "ok": true, "detail": "109,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -64 · 기 +919"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,864"}]}
|
||||
{"ts": "2026-07-13T09:12:04.291897+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 341500, "qty": 11, "cost": 3757063, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 300363, "target": 403206, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "341,000 vs 305,450"}, {"label": "정배열(5>10)", "ok": true, "detail": "314,400 / 305,450"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "314,400 / 305,433"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -63 · 기 +144 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.90% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "197,687 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.90% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "64"}]}
|
||||
{"ts": "2026-07-13T09:26:03.403270+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 139100, "qty": 14, "cost": 1947692, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 96000, "target": 171500, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 139,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 171,500"}, {"label": "추세(10일선)", "ok": true, "detail": "126,460"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -213 · 기 +772"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 136,162"}]}
|
||||
{"ts": "2026-07-13T10:04:06.404637+09:00", "side": "BUY", "code": "055550", "name": "신한지주", "price": 113100, "qty": 42, "cost": 4750913, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 101931, "target": 129854, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "113,050 vs 100,265"}, {"label": "정배열(5>10)", "ok": true, "detail": "101,790 / 100,265"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "101,790 / 96,676"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+31.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -368 · 기 +1,005 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.71% (환산) vs 평균 0.35% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 109,800"}, {"label": "거래량 급증", "ok": true, "detail": "3,366,381 (환산) vs 평균 1,653,581"}, {"label": "회전율 급증", "ok": true, "detail": "0.71% (환산) vs 평균 0.35%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-15T14:32:01.876272+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18570, "qty": 602, "proceeds": 11157341, "entry_price": 19858, "pnl": -799013, "pnl_pct": -6.49, "hold_from": "2026-07-03T10:24:02.768192+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "18,586 (현재 18,570)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,766"}, {"label": "추세(10일선)", "ok": false, "detail": "18,905"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -214 · 기 +240"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-16T09:00:08.142600+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 209000, "qty": 23, "proceeds": 4797626, "entry_price": 165991, "pnl": 979254, "pnl_pct": 25.91, "hold_from": "2026-07-07T09:12:07.261674+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "210,764 (현재 207,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "158,280"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -799 · 기 +891"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-16T09:28:03.474549+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 353000, "qty": 11, "cost": 3883582, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 300363, "target": 403206, "signals": [{"label": "손절선", "ok": true, "detail": "300,363 (현재 352,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 403,206"}, {"label": "추세(10일선)", "ok": true, "detail": "306,600"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -33 · 기 +28"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 351,963"}]}
|
||||
{"ts": "2026-07-20T11:28:01.183314+09:00", "side": "SELL", "code": "055550", "name": "신한지주", "price": 101900, "qty": 42, "proceeds": 4271454, "entry_price": 113100, "pnl": -479458, "pnl_pct": -9.9, "hold_from": "2026-07-13T10:04:06.404635+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "101,931 (현재 101,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 129,854"}, {"label": "추세(10일선)", "ok": true, "detail": "99,150"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -156 · 기 +275"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 115,714"}]}
|
||||
{"ts": "2026-07-20T12:28:02.308175+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 10310, "qty": 143, "proceeds": 1471455, "entry_price": 13660, "pnl": -482218, "pnl_pct": -24.52, "hold_from": "2026-07-10T11:06:06.463231+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "10,360 (현재 10,310)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,610"}, {"label": "추세(10일선)", "ok": false, "detail": "12,013"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -887 · 기 +515"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,626"}]}
|
||||
{"ts": "2026-07-22T14:18:05.417998+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14030, "qty": 158, "cost": 2217073, "path": "pullback", "reason": "눌림목 지정가 14,023 도달", "stop": 11077, "target": 18460, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,020 vs 12,975"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,552 / 12,975"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,552 / 13,283"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,005 · 기 +1,723 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.97% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "585,754 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.97% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "53"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 14,023 (현재 14,020)"}]}
|
||||
{"ts": "2026-07-22T15:04:00.988784+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158100, "qty": 12, "cost": 1897485, "path": "pullback", "reason": "눌림목 지정가 158,107 도달", "stop": 120314, "target": 214778, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "158,000 vs 153,380"}, {"label": "정배열(5>10)", "ok": true, "detail": "172,240 / 153,380"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "172,240 / 99,750"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+49.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -311 · 기 +22 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+24%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "5.02% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "971,360 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": true, "detail": "5.02% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,107 (현재 158,000)"}]}
|
||||
{"ts": "2026-07-23T09:50:01.507037+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 88200, "qty": 44, "cost": 3881382, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 77564, "target": 104155, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "88,200 vs 80,900"}, {"label": "정배열(5>10)", "ok": true, "detail": "83,000 / 80,900"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,000 / 75,058"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+47.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -150 · 기 +299 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.85% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "785,587 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.85% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-07-23T10:24:01.496618+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 152100, "qty": 12, "proceeds": 1821641, "entry_price": 158100, "pnl": -75844, "pnl_pct": -3.8, "hold_from": "2026-07-22T15:04:00.988783+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "120,314 (현재 152,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 214,778"}, {"label": "추세(10일선)", "ok": false, "detail": "152,780"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -101 · 기 -107"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 167,575"}]}
|
||||
{"ts": "2026-07-23T10:30:02.476529+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 14140, "qty": 192, "cost": 2715287, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11706, "target": 17791, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,180 vs 12,429"}, {"label": "정배열(5>10)", "ok": true, "detail": "13,154 / 12,429"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "13,154 / 12,687"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+55.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +109 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "84.26% (환산) vs 평균 31.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 13,990"}, {"label": "거래량 급증", "ok": true, "detail": "12,639,979 (환산) vs 평균 4,720,532"}, {"label": "회전율 급증", "ok": true, "detail": "84.26% (환산) vs 평균 31.47%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-23T12:54:04.489474+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 90900, "qty": 42, "cost": 3818373, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 77564, "target": 104155, "signals": [{"label": "손절선", "ok": true, "detail": "77,564 (현재 91,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 104,155"}, {"label": "추세(10일선)", "ok": true, "detail": "81,180"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -169 · 기 +311"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 90,945"}]}
|
||||
{"ts": "2026-07-24T09:00:05.529199+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 111800, "qty": 29, "proceeds": 3235878, "entry_price": 132428, "pnl": -605098, "pnl_pct": -15.58, "hold_from": "2026-07-10T12:14:02.664484+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 113,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 187,069"}, {"label": "추세(10일선)", "ok": false, "detail": "123,880"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -480 · 기 -206"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-24T09:00:05.530375+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 155500, "qty": 12, "cost": 1866280, "path": "pullback", "reason": "눌림목 지정가 158,093 도달", "stop": 117686, "target": 212221, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "155,000 vs 153,080"}, {"label": "정배열(5>10)", "ok": true, "detail": "171,640 / 153,080"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,640 / 99,700"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+44.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +200 · 기 -454 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+27%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.77% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "730,327 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "3.77% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,093 (현재 155,000)"}]}
|
||||
{"ts": "2026-07-24T09:02:04.036333+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 15060, "qty": 181, "cost": 2726269, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11706, "target": 17791, "signals": [{"label": "손절선", "ok": true, "detail": "11,706 (현재 15,020)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,791"}, {"label": "추세(10일선)", "ok": true, "detail": "12,513"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +208 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,766"}]}
|
||||
{"ts": "2026-07-24T09:28:04.942689+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14510, "qty": 153, "cost": 2220363, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11077, "target": 18460, "signals": [{"label": "손절선", "ok": true, "detail": "11,077 (현재 15,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,460"}, {"label": "추세(10일선)", "ok": true, "detail": "13,113"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -857 · 기 +1,172"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,791"}]}
|
||||
{"ts": "2026-07-24T10:02:04.391526+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18950, "qty": 312, "cost": 5913287, "path": "pullback", "reason": "눌림목 지정가 20,012 도달", "stop": 17760, "target": 20735, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,950 vs 18,943"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,576 / 18,943"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,576 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+19.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +2 · 기 -5 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.39% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "454,069 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.39% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,012 (현재 18,950)"}]}
|
||||
{"ts": "2026-07-27T09:10:01.034991+09:00", "side": "SELL", "code": "024060", "name": "흥구석유", "price": 12030, "qty": 373, "proceeds": 4478440, "entry_price": 14586, "pnl": -963116, "pnl_pct": -17.53, "hold_from": "2026-07-23T10:30:02.476527+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "12,083 (현재 12,030)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,342"}, {"label": "추세(10일선)", "ok": false, "detail": "12,214"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -85 · 기 +2"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-27T09:12:01.501479+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7850, "qty": 420, "cost": 3297495, "path": "pullback", "reason": "눌림목 지정가 7,907 도달", "stop": 6743, "target": 9510, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "7,840 vs 5,428"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,526 / 5,428"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,526 / 4,895"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+91.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +49 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "11.17% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "693,433 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": true, "detail": "11.17% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": false, "detail": "80"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,907 (현재 7,840)"}]}
|
||||
{"ts": "2026-07-27T12:56:04.228682+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 8300, "qty": 397, "cost": 3295594, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 6743, "target": 9510, "signals": [{"label": "손절선", "ok": true, "detail": "6,743 (현재 8,310)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,510"}, {"label": "추세(10일선)", "ok": true, "detail": "5,476"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +50 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 8,127"}]}
|
||||
{"ts": "2026-07-27T13:38:04.225254+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 6880, "qty": 817, "proceeds": 5609999, "entry_price": 8069, "pnl": -983090, "pnl_pct": -14.73, "hold_from": "2026-07-27T09:12:01.501476+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "6,962 (현재 6,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,729"}, {"label": "추세(10일선)", "ok": true, "detail": "5,334"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +50 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-28T09:00:15.725820+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6780, "qty": 394, "cost": 2671721, "path": "pullback", "reason": "눌림목 지정가 7,884 도달", "stop": 5628, "target": 8509, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "7,050 vs 5,350"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,368 / 5,350"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,368 / 4,882"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+73.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +86 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.01% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "813 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "0.01% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "69"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,884 (현재 7,050)"}]}
|
||||
{"ts": "2026-07-28T09:10:01.736195+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7100, "qty": 376, "cost": 2670000, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5628, "target": 8509, "signals": [{"label": "손절선", "ok": true, "detail": "5,628 (현재 7,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,509"}, {"label": "추세(10일선)", "ok": true, "detail": "5,354"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +86 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,079"}]}
|
||||
{"ts": "2026-07-28T10:08:02.763925+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 11170, "qty": 311, "proceeds": 3467096, "entry_price": 14266, "pnl": -970340, "pnl_pct": -21.7, "hold_from": "2026-07-22T14:18:05.417997+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "11,223 (현재 11,220)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,831"}, {"label": "추세(10일선)", "ok": false, "detail": "12,695"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +106 · 기 -64"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:02:02.218183+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 117900, "qty": 47, "cost": 5542131, "path": "pullback", "reason": "눌림목 지정가 118,115 도달", "stop": 111200, "target": 127950, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "117,800 vs 116,390"}, {"label": "정배열(5>10)", "ok": true, "detail": "117,020 / 116,390"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "117,020 / 109,982"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+45.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +39 · 기 +307 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+26%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 123,300"}, {"label": "거래량 급증", "ok": false, "detail": "44,660 (환산) vs 평균 289,522"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.17%"}, {"label": "RSI", "ok": true, "detail": "57"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 118,115 (현재 117,800)"}]}
|
||||
{"ts": "2026-07-29T09:16:04.458945+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 120500, "qty": 46, "cost": 5543831, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111200, "target": 127950, "signals": [{"label": "손절선", "ok": true, "detail": "111,200 (현재 120,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 127,950"}, {"label": "추세(10일선)", "ok": true, "detail": "116,660"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +39 · 기 +307"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 120,492"}]}
|
||||
{"ts": "2026-07-29T09:24:08.688483+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 122600, "qty": 15, "cost": 1839276, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 117686, "target": 212221, "signals": [{"label": "손절선", "ok": true, "detail": "117,686 (현재 177,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 212,221"}, {"label": "추세(10일선)", "ok": true, "detail": "148,710"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +453 · 기 -688"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 164,742"}]}
|
||||
{"ts": "2026-07-29T09:24:08.688690+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 128600, "qty": 59, "proceeds": 7572605, "entry_price": 134408, "pnl": -358685, "pnl_pct": -4.32, "hold_from": "2026-07-09T09:36:03.450092+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "118,952 (현재 116,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 157,593"}, {"label": "추세(10일선)", "ok": true, "detail": "105,620"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +245"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:26:01.891082+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18940, "qty": 312, "proceeds": 5897757, "entry_price": 18950, "pnl": -15530, "pnl_pct": -0.05, "hold_from": "2026-07-24T10:02:04.391524+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "19,645 (현재 18,950)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "18,943"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +99 · 기 +138"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:28:01.377044+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6390, "qty": 850, "cost": 5432315, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5867, "target": 7174, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,380 vs 5,591"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,742 / 5,591"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,742 / 5,622"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+43.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +73 · 기 -3 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.62% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "134,413 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.62% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "73"}]}
|
||||
{"ts": "2026-07-29T10:18:05.366579+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 116400, "qty": 27, "proceeds": 3136672, "entry_price": 137222, "pnl": -568884, "pnl_pct": -15.17, "hold_from": "2026-07-24T09:00:05.530369+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "117,686 (현재 115,800)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 192,006"}, {"label": "추세(10일선)", "ok": false, "detail": "149,160"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +445 · 기 -685"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T12:04:01.681905+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 119200, "qty": 85, "proceeds": 10112243, "entry_price": 124176, "pnl": -444341, "pnl_pct": -4.01, "hold_from": "2026-07-02T10:18:03.827419+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "111,006 (현재 119,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 143,933"}, {"label": "추세(10일선)", "ok": false, "detail": "119,490"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -11 · 기 -51"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T13:06:06.472442+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 5690, "qty": 770, "proceeds": 4372756, "entry_price": 6936, "pnl": -968965, "pnl_pct": -17.97, "hold_from": "2026-07-28T09:00:15.725812+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,741 (현재 5,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,729"}, {"label": "추세(10일선)", "ok": true, "detail": "5,214"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +185 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T13:34:03.581955+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5860, "qty": 850, "proceeds": 4971287, "entry_price": 6390, "pnl": -461028, "pnl_pct": -8.29, "hold_from": "2026-07-29T09:28:01.377043+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,867 (현재 5,860)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,174"}, {"label": "추세(10일선)", "ok": true, "detail": "5,539"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +73 · 기 -3"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,525"}]}
|
||||
{"ts": "2026-07-30T09:04:06.862580+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 121300, "qty": 42, "cost": 5095364, "path": "pullback", "reason": "눌림목 지정가 122,665 도달", "stop": 111000, "target": 136750, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "121,200 vs 119,680"}, {"label": "정배열(5>10)", "ok": true, "detail": "120,840 / 119,680"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "120,840 / 118,418"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+34.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -214 · 기 +182 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+36%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.18% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": false, "detail": "494,880 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": false, "detail": "0.18% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 122,665 (현재 121,200)"}]}
|
||||
{"ts": "2026-07-30T09:04:06.862756+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19090, "qty": 286, "cost": 5460559, "path": "pullback", "reason": "눌림목 지정가 20,019 도달", "stop": 17760, "target": 21085, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,100 vs 18,958"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,606 / 18,958"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,606 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+29.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +2 · 기 +327 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+32%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.14% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "160,727 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.14% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,019 (현재 19,100)"}]}
|
||||
{"ts": "2026-07-30T09:04:06.863066+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6130, "qty": 326, "cost": 1998680, "path": "pullback", "reason": "눌림목 지정가 7,792 도달", "stop": 4793, "target": 8135, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,120 vs 5,256"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,182 / 5,256"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,182 / 4,866"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+65.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +234 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.31% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "267,687 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "4.31% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,792 (현재 6,120)"}]}
|
||||
{"ts": "2026-07-30T09:16:11.313716+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 125200, "qty": 41, "cost": 5133970, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111000, "target": 136750, "signals": [{"label": "손절선", "ok": true, "detail": "111,000 (현재 125,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 136,750"}, {"label": "추세(10일선)", "ok": true, "detail": "120,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -214 · 기 +182"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 124,618"}]}
|
||||
{"ts": "2026-07-30T09:24:06.914861+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 131800, "qty": 29, "cost": 3822773, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 116987, "target": 154020, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "132,000 vs 108,670"}, {"label": "정배열(5>10)", "ok": true, "detail": "115,580 / 108,670"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,580 / 113,230"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+51.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -6 · 기 +178 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+20%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.66% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "738,813 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.66% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-30T09:26:06.866302+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6300, "qty": 865, "cost": 5450317, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5789, "target": 7067, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,280 vs 5,581"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,722 / 5,581"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,722 / 5,620"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+46.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +87 · 기 +6 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.51% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "109,080 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.51% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "71"}]}
|
||||
{"ts": "2026-07-31T09:06:06.410935+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6550, "qty": 305, "cost": 1998050, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 4793, "target": 8135, "signals": [{"label": "손절선", "ok": true, "detail": "4,793 (현재 6,640)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,135"}, {"label": "추세(10일선)", "ok": true, "detail": "5,308"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +355 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,453"}]}
|
||||
{"ts": "2026-07-31T09:06:06.411045+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 77700, "qty": 86, "proceeds": 6669170, "entry_price": 89519, "pnl": -1030585, "pnl_pct": -13.2, "hold_from": "2026-07-23T09:50:01.507036+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "78,539 (현재 78,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 105,988"}, {"label": "추세(10일선)", "ok": false, "detail": "79,880"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -179 · 기 +325"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-03T12:00:11.189299+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 12910, "qty": 270, "cost": 3486223, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11314, "target": 15304, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "12,930 vs 12,003"}, {"label": "정배열(5>10)", "ok": true, "detail": "12,138 / 12,003"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "12,138 / 11,924"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +7 · 기 +10 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+74%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.72% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 12,870"}, {"label": "거래량 급증", "ok": true, "detail": "208,994 (환산) vs 평균 99,303"}, {"label": "회전율 급증", "ok": true, "detail": "0.72% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-03T12:02:09.144443+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 13420, "qty": 260, "cost": 3489723, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11314, "target": 15304, "signals": [{"label": "손절선", "ok": true, "detail": "11,314 (현재 13,450)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 15,304"}, {"label": "추세(10일선)", "ok": true, "detail": "12,055"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +7 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,339"}]}
|
||||
{"ts": "2026-08-05T09:02:12.583140+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5700, "qty": 865, "proceeds": 4920886, "entry_price": 6300, "pnl": -529432, "pnl_pct": -9.52, "hold_from": "2026-07-30T09:26:06.866300+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,789 (현재 5,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,067"}, {"label": "추세(10일선)", "ok": true, "detail": "5,526"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +14 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,413"}]}
|
||||
{"ts": "2026-08-05T09:02:12.583322+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13040, "qty": 160, "cost": 2086713, "path": "pullback", "reason": "눌림목 지정가 13,947 도달", "stop": 10340, "target": 17090, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,040 vs 12,877"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,356 / 12,877"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,356 / 13,267"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-6.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -72 · 기 +134 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.13% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "78,333 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.13% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,947 (현재 13,040)"}]}
|
||||
{"ts": "2026-08-05T09:38:10.849723+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13830, "qty": 151, "cost": 2088643, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10340, "target": 17090, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 13,860)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,090"}, {"label": "추세(10일선)", "ok": true, "detail": "12,959"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -72 · 기 +134"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,817"}]}
|
||||
{"ts": "2026-08-06T09:00:17.815534+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18730, "qty": 286, "proceeds": 5346334, "entry_price": 19090, "pnl": -114225, "pnl_pct": -1.89, "hold_from": "2026-07-30T09:04:06.862754+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,085"}, {"label": "추세(10일선)", "ok": false, "detail": "18,923"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -203 · 기 -147"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,459"}]}
|
||||
{"ts": "2026-08-06T09:34:15.272349+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 192000, "qty": 26, "cost": 4992749, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 175344, "target": 216984, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "191,800 vs 180,670"}, {"label": "정배열(5>10)", "ok": true, "detail": "186,140 / 180,670"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "186,140 / 176,671"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +255 · 기 -319 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+44%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.67% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 191,700"}, {"label": "거래량 급증", "ok": true, "detail": "1,582,767 (환산) vs 평균 738,783"}, {"label": "회전율 급증", "ok": true, "detail": "0.67% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-07T09:12:15.074213+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 196400, "qty": 25, "cost": 4910736, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 175344, "target": 216984, "signals": [{"label": "손절선", "ok": true, "detail": "175,344 (현재 196,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 216,984"}, {"label": "추세(10일선)", "ok": true, "detail": "181,120"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +338 · 기 +73"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 196,264"}]}
|
||||
{"ts": "2026-08-10T09:00:14.888891+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 136000, "qty": 28, "cost": 3808571, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 116987, "target": 154020, "signals": [{"label": "손절선", "ok": true, "detail": "116,987 (현재 137,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,020"}, {"label": "추세(10일선)", "ok": true, "detail": "109,170"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -253 · 기 +225"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,714"}]}
|
||||
{"ts": "2026-08-12T09:30:11.933981+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 400000, "qty": 22, "proceeds": 8782840, "entry_price": 347250, "pnl": 1142194, "pnl_pct": 15.19, "hold_from": "2026-07-13T09:12:04.291895+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "399,647 (현재 399,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "311,300"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -6 · 기 +72"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-12T09:38:12.221096+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19050, "qty": 294, "cost": 5601540, "path": "pullback", "reason": "눌림목 지정가 20,036 도달", "stop": 17760, "target": 20985, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,040 vs 18,952"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,594 / 18,952"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,594 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +113 · 기 +20 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+32%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.22% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "250,373 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.22% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,036 (현재 19,040)"}]}
|
||||
{"ts": "2026-08-13T09:00:30.658185+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18880, "qty": 294, "proceeds": 5539896, "entry_price": 19050, "pnl": -61644, "pnl_pct": -0.89, "hold_from": "2026-08-12T09:38:12.221094+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,930)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,985"}, {"label": "추세(10일선)", "ok": false, "detail": "18,941"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -50 · 기 -76"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,411"}]}
|
||||
{"ts": "2026-08-13T09:08:16.774337+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 402500, "qty": 8, "cost": 3220483, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 352720, "target": 477170, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "402,000 vs 311,550"}, {"label": "정배열(5>10)", "ok": true, "detail": "326,600 / 311,550"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "326,600 / 306,450"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +27 · 기 +23 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+26%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.20% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "228,347 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "2.20% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "75"}]}
|
||||
{"ts": "2026-08-13T14:02:16.527631+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 415500, "qty": 8, "cost": 3324499, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 352720, "target": 477170, "signals": [{"label": "손절선", "ok": true, "detail": "352,720 (현재 415,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 477,170"}, {"label": "추세(10일선)", "ok": true, "detail": "312,900"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +19"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 415,052"}]}
|
||||
{"ts": "2026-08-24T13:12:20.462527+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 5030, "qty": 631, "proceeds": 3167741, "entry_price": 6333, "pnl": -828989, "pnl_pct": -20.57, "hold_from": "2026-07-30T09:04:06.863063+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,039 (현재 5,030)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,274"}, {"label": "추세(10일선)", "ok": false, "detail": "5,148"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -31 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-24T13:12:20.462991+09:00", "side": "BUY", "code": "316140", "name": "우리금융지주", "price": 31900, "qty": 167, "cost": 5328099, "path": "pullback", "reason": "눌림목 지정가 31,939 도달", "stop": 29900, "target": 34900, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "31,850 vs 31,795"}, {"label": "정배열(5>10)", "ok": true, "detail": "32,390 / 31,795"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "32,390 / 30,876"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -892 · 기 +171 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+37%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.24% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 33,850"}, {"label": "거래량 급증", "ok": false, "detail": "1,768,231 (환산) vs 평균 2,489,243"}, {"label": "회전율 급증", "ok": false, "detail": "0.24% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "53"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 31,939 (현재 31,850)"}]}
|
||||
{"ts": "2026-08-25T11:36:17.992170+09:00", "side": "BUY", "code": "316140", "name": "우리금융지주", "price": 32750, "qty": 162, "cost": 5306296, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 29900, "target": 34900, "signals": [{"label": "손절선", "ok": true, "detail": "29,900 (현재 32,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 34,900"}, {"label": "추세(10일선)", "ok": true, "detail": "31,885"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,322 · 기 +436"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 32,722"}]}
|
||||
{"ts": "2026-09-01T09:02:25.877997+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 12660, "qty": 311, "proceeds": 3929582, "entry_price": 13424, "pnl": -245774, "pnl_pct": -5.69, "hold_from": "2026-08-05T09:02:12.583320+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "10,340 (현재 12,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,049"}, {"label": "추세(10일선)", "ok": false, "detail": "12,846"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -53 · 기 -347"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-01T09:10:27.212333+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19310, "qty": 288, "cost": 5562114, "path": "pullback", "reason": "눌림목 지정가 20,043 도달", "stop": 17896, "target": 21431, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,310 vs 18,979"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,648 / 18,979"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,648 / 19,364"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-9.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -11 · 기 +18 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+30%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.16% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "182,700 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.16% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,043 (현재 19,310)"}]}
|
||||
{"ts": "2026-09-03T09:10:25.793603+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 145600, "qty": 57, "proceeds": 8283017, "entry_price": 133863, "pnl": 651672, "pnl_pct": 8.77, "hold_from": "2026-07-30T09:24:06.914859+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "145,883 (현재 145,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "110,030"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -31 · 기 +240"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-03T09:56:26.998125+09:00", "side": "BUY", "code": "003550", "name": "LG", "price": 122000, "qty": 36, "cost": 4392659, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 109684, "target": 140474, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "122,000 vs 106,970"}, {"label": "정배열(5>10)", "ok": true, "detail": "111,120 / 106,970"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "111,120 / 107,847"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+18.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -119 · 기 +22 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+14%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.37% (환산) vs 평균 0.19% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 111,900"}, {"label": "거래량 급증", "ok": true, "detail": "575,367 (환산) vs 평균 280,124"}, {"label": "회전율 급증", "ok": true, "detail": "0.37% (환산) vs 평균 0.19%"}, {"label": "RSI", "ok": true, "detail": "71"}]}
|
||||
{"ts": "2026-09-04T11:14:26.494620+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18930, "qty": 288, "proceeds": 5441209, "entry_price": 19310, "pnl": -120905, "pnl_pct": -1.97, "hold_from": "2026-09-01T09:10:27.212331+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,896 (현재 18,940)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,431"}, {"label": "추세(10일선)", "ok": false, "detail": "18,942"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -2 · 기 -39"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,673"}]}
|
||||
{"ts": "2026-09-04T11:14:26.495106+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13590, "qty": 141, "cost": 1916477, "path": "pullback", "reason": "눌림목 지정가 13,946 도달", "stop": 10481, "target": 18253, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,590 vs 12,932"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,466 / 12,932"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,466 / 13,276"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-7.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -81 · 기 +73 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+66%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.98% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "588,939 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.98% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,946 (현재 13,590)"}]}
|
||||
{"ts": "2026-09-04T12:34:27.137943+09:00", "side": "SELL", "code": "068270", "name": "셀트리온", "price": 187800, "qty": 51, "proceeds": 9559123, "entry_price": 194157, "pnl": -344362, "pnl_pct": -3.27, "hold_from": "2026-08-06T09:34:15.272347+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "177,101 (현재 187,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 219,741"}, {"label": "추세(10일선)", "ok": false, "detail": "189,720"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -128 · 기 -47"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-04T12:36:28.825231+09:00", "side": "BUY", "code": "079650", "name": "서산", "price": 6800, "qty": 243, "cost": 1652648, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 4991, "target": 9513, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,780 vs 5,060"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,202 / 5,060"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,202 / 4,364"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+58.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +193 · 기 +5 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "47.40% (환산) vs 평균 23.23% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 6,760"}, {"label": "거래량 급증", "ok": true, "detail": "9,478,267 (환산) vs 평균 4,645,030"}, {"label": "회전율 급증", "ok": true, "detail": "47.40% (환산) vs 평균 23.23%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-09-08T12:32:31.605944+09:00", "side": "BUY", "code": "079650", "name": "서산", "price": 7390, "qty": 223, "cost": 1648217, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 4991, "target": 9513, "signals": [{"label": "손절선", "ok": true, "detail": "4,991 (현재 7,380)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,513"}, {"label": "추세(10일선)", "ok": true, "detail": "5,120"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +103 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,239"}]}
|
||||
{"ts": "2026-09-08T15:20:32.428940+09:00", "side": "SELL", "code": "138040", "name": "메리츠금융지주", "price": 130000, "qty": 93, "proceeds": 12066424, "entry_price": 119186, "pnl": 980462, "pnl_pct": 9.07, "hold_from": "2026-07-29T09:02:02.218181+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "130,576 (현재 130,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "117,640"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -147 · 기 +423"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-09T09:02:32.227738+09:00", "side": "BUY", "code": "000720", "name": "현대건설", "price": 135800, "qty": 23, "cost": 3123869, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 117206, "target": 163692, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "135,800 vs 115,200"}, {"label": "정배열(5>10)", "ok": true, "detail": "116,420 / 115,200"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "116,420 / 113,853"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+34.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -280 · 기 +899 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+22%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.32% (환산) vs 평균 0.58% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 122,600"}, {"label": "거래량 급증", "ok": true, "detail": "1,470,387 (환산) vs 평균 648,608"}, {"label": "회전율 급증", "ok": true, "detail": "1.32% (환산) vs 평균 0.58%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-09-09T09:06:32.111702+09:00", "side": "BUY", "code": "000720", "name": "현대건설", "price": 141500, "qty": 22, "cost": 3113467, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 117206, "target": 163692, "signals": [{"label": "손절선", "ok": true, "detail": "117,206 (현재 141,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 163,692"}, {"label": "추세(10일선)", "ok": true, "detail": "115,740"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -280 · 기 +899"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 140,627"}]}
|
||||
{"ts": "2026-09-10T09:06:30.775863+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 356500, "qty": 16, "proceeds": 5692877, "entry_price": 409000, "pnl": -852104, "pnl_pct": -12.84, "hold_from": "2026-08-13T09:08:16.774335+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "358,791 (현재 358,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 484,313"}, {"label": "추세(10일선)", "ok": true, "detail": "307,150"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +106 · 기 -147"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-10T14:02:37.657665+09:00", "side": "BUY", "code": "004370", "name": "농심", "price": 410500, "qty": 12, "cost": 4926739, "path": "pullback", "reason": "눌림목 지정가 420,141 도달", "stop": 375781, "target": 462578, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "410,500 vs 396,800"}, {"label": "정배열(5>10)", "ok": true, "detail": "404,600 / 396,800"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "404,600 / 369,900"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +8 · 기 -29 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.27% (환산) vs 평균 0.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 440,000"}, {"label": "거래량 급증", "ok": false, "detail": "16,224 (환산) vs 평균 31,191"}, {"label": "회전율 급증", "ok": false, "detail": "0.27% (환산) vs 평균 0.51%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 420,141 (현재 410,500)"}]}
|
||||
{"ts": "2026-09-11T09:04:34.978393+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14660, "qty": 131, "cost": 1920748, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10481, "target": 18253, "signals": [{"label": "손절선", "ok": true, "detail": "10,481 (현재 14,510)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,253"}, {"label": "추세(10일선)", "ok": true, "detail": "13,024"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -103 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,345"}]}
|
||||
{"ts": "2026-09-15T09:01:02.932908+09:00", "side": "SELL", "code": "003550", "name": "LG", "price": 113400, "qty": 36, "proceeds": 4074439, "entry_price": 122000, "pnl": -318219, "pnl_pct": -7.05, "hold_from": "2026-09-03T09:56:26.998121+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "109,684 (현재 111,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 140,474"}, {"label": "추세(10일선)", "ok": false, "detail": "118,280"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -16 · 기 -177"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 124,558"}]}
|
||||
{"ts": "2026-09-15T09:04:39.495078+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 11780, "qty": 286, "cost": 3369585, "path": "pullback", "reason": "눌림목 지정가 11,977 도달", "stop": 10254, "target": 14069, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "11,790 vs 11,347"}, {"label": "정배열(5>10)", "ok": true, "detail": "11,788 / 11,347"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "11,788 / 9,429"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+35.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +26 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 0.30% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 13,450"}, {"label": "거래량 급증", "ok": false, "detail": "313 (환산) vs 평균 36,789"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 0.30%"}, {"label": "RSI", "ok": true, "detail": "65"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 11,977 (현재 11,790)"}]}
|
||||
{"ts": "2026-09-17T13:50:40.410598+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 12170, "qty": 276, "cost": 3359424, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10254, "target": 14069, "signals": [{"label": "손절선", "ok": true, "detail": "10,254 (현재 12,170)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 14,069"}, {"label": "추세(10일선)", "ok": true, "detail": "11,385"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +16 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 12,163"}]}
|
||||
{"ts": "2026-09-21T15:08:43.620924+09:00", "side": "SELL", "code": "004370", "name": "농심", "price": 392500, "qty": 12, "proceeds": 4700816, "entry_price": 410500, "pnl": -225923, "pnl_pct": -4.38, "hold_from": "2026-09-10T14:02:37.657664+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "375,781 (현재 392,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 462,578"}, {"label": "추세(10일선)", "ok": false, "detail": "395,000"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -1 · 기 -5"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 419,823"}]}
|
||||
{"ts": "2026-09-22T09:02:46.938301+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 153600, "qty": 11, "cost": 1689853, "path": "pullback", "reason": "눌림목 지정가 157,986 도달", "stop": 115572, "target": 210643, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "153,000 vs 152,880"}, {"label": "정배열(5>10)", "ok": true, "detail": "171,240 / 152,880"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,240 / 99,667"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +116 · 기 -19 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+40%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.27% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "52,007 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "0.27% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "58"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 157,986 (현재 153,000)"}]}
|
||||
{"ts": "2026-09-23T12:32:46.718334+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 132200, "qty": 83, "proceeds": 10951203, "entry_price": 123227, "pnl": 721869, "pnl_pct": 7.28, "hold_from": "2026-07-30T09:04:06.862578+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "132,275 (현재 132,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "120,780"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -350 · 기 +269"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-23T12:32:47.146555+09:00", "side": "BUY", "code": "372170", "name": "윤성에프앤씨", "price": 20050, "qty": 110, "cost": 2205831, "path": "pullback", "reason": "눌림목 지정가 21,336 도달", "stop": 16123, "target": 25941, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "20,000 vs 19,058"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,636 / 19,058"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,636 / 19,446"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+35.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +1 · 기 -1 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.06% (환산) vs 평균 0.56% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 23,400"}, {"label": "거래량 급증", "ok": false, "detail": "5,027 (환산) vs 평균 44,688"}, {"label": "회전율 급증", "ok": false, "detail": "0.06% (환산) vs 평균 0.56%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 21,336 (현재 20,000)"}]}
|
||||
{"ts": "2026-09-28T09:04:45.105461+09:00", "side": "SELL", "code": "000720", "name": "현대건설", "price": 119200, "qty": 45, "proceeds": 5353540, "entry_price": 138587, "pnl": -883795, "pnl_pct": -13.99, "hold_from": "2026-09-09T09:02:32.227735+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "119,278 (현재 119,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 167,550"}, {"label": "추세(10일선)", "ok": true, "detail": "113,530"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -440 · 기 -358"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-28T09:04:45.106990+09:00", "side": "BUY", "code": "025860", "name": "남해화학", "price": 6050, "qty": 895, "cost": 5415562, "path": "pullback", "reason": "눌림목 지정가 6,148 도달", "stop": 5730, "target": 6530, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,060 vs 6,027"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,098 / 6,027"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,098 / 5,899"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+9.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -32 · 기 +15 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.09% (환산) vs 평균 1.53% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 6,700"}, {"label": "거래량 급증", "ok": false, "detail": "45,067 (환산) vs 평균 758,257"}, {"label": "회전율 급증", "ok": false, "detail": "0.09% (환산) vs 평균 1.53%"}, {"label": "RSI", "ok": true, "detail": "53"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 6,148 (현재 6,060)"}]}
|
||||
{"ts": "2026-09-28T09:20:48.364329+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 164000, "qty": 10, "cost": 1640246, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 115572, "target": 210643, "signals": [{"label": "손절선", "ok": true, "detail": "115,572 (현재 163,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 210,643"}, {"label": "추세(10일선)", "ok": true, "detail": "153,880"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +222 · 기 -12"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 162,889"}]}
|
||||
{"ts": "2026-10-01T09:00:53.929412+09:00", "side": "SELL", "code": "316140", "name": "우리금융지주", "price": 32800, "qty": 329, "proceeds": 10770157, "entry_price": 32319, "pnl": 135762, "pnl_pct": 1.49, "hold_from": "2026-08-24T13:12:20.462989+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "34,483 (현재 33,850)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "31,995"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -303 · 기 -158"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-01T09:02:45.598935+09:00", "side": "BUY", "code": "051900", "name": "LG생활건강", "price": 271500, "qty": 14, "cost": 3801570, "path": "pullback", "reason": "눌림목 지정가 277,258 도달", "stop": 242017, "target": 315725, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "270,000 vs 256,700"}, {"label": "정배열(5>10)", "ok": true, "detail": "267,300 / 256,700"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "267,300 / 248,933"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -51 · 기 +19 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+20%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.15% (환산) vs 평균 0.53% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 314,500"}, {"label": "거래량 급증", "ok": false, "detail": "22,107 (환산) vs 평균 78,860"}, {"label": "회전율 급증", "ok": false, "detail": "0.15% (환산) vs 평균 0.53%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 277,258 (현재 270,000)"}]}
|
||||
{"ts": "2026-10-02T09:01:23.796646+09:00", "side": "BUY", "code": "372170", "name": "윤성에프앤씨", "price": 21400, "qty": 103, "cost": 2204531, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 16123, "target": 25941, "signals": [{"label": "손절선", "ok": true, "detail": "16,123 (현재 21,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 25,941"}, {"label": "추세(10일선)", "ok": true, "detail": "19,228"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +2 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 20,989"}]}
|
||||
{"ts": "2026-10-08T09:04:49.324670+09:00", "side": "SELL", "code": "079650", "name": "서산", "price": 5300, "qty": 466, "proceeds": 2464984, "entry_price": 7082, "pnl": -835881, "pnl_pct": -25.17, "hold_from": "2026-09-04T12:36:28.825230+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,326 (현재 5,290)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,716"}, {"label": "추세(10일선)", "ok": true, "detail": "4,910"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -30 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-08T09:50:45.349013+09:00", "side": "BUY", "code": "056360", "name": "코위버", "price": 6360, "qty": 731, "cost": 4649857, "path": "pullback", "reason": "눌림목 지정가 6,523 도달", "stop": 5770, "target": 7245, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,360 vs 6,318"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,492 / 6,318"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,492 / 5,507"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+13.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +22 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.61% (환산) vs 평균 2.14% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 7,140"}, {"label": "거래량 급증", "ok": false, "detail": "157,353 (환산) vs 평균 209,822"}, {"label": "회전율 급증", "ok": false, "detail": "1.61% (환산) vs 평균 2.14%"}, {"label": "RSI", "ok": true, "detail": "57"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 6,523 (현재 6,360)"}]}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944261+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.281173+09:00"
|
||||
}
|
||||
@@ -1,35 +1,213 @@
|
||||
{
|
||||
"cash": 93565534.975,
|
||||
"cash": 27884752.730150037,
|
||||
"initial": 100000000,
|
||||
"positions": {
|
||||
"035420": {
|
||||
"code": "035420",
|
||||
"name": "NAVER",
|
||||
"252990": {
|
||||
"code": "252990",
|
||||
"name": "샘씨엔에스",
|
||||
"sources": [
|
||||
"interest"
|
||||
"auto"
|
||||
],
|
||||
"qty": 27,
|
||||
"entry_price": 238277.77777777778,
|
||||
"entry_at": "2026-06-12T09:00:34.009427+09:00",
|
||||
"stop": 203394,
|
||||
"target": 308046,
|
||||
"peak": 263000,
|
||||
"qty": 335,
|
||||
"entry_price": 14067.880597014926,
|
||||
"entry_at": "2026-09-03T09:02:24.986233+09:00",
|
||||
"stop": 18268,
|
||||
"target": 18598,
|
||||
"peak": 20700,
|
||||
"trailing_on": true,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 13,982 도달",
|
||||
"cur_price": 18340,
|
||||
"tranches": 2,
|
||||
"tranche_value": 2363869.6412348715,
|
||||
"last_add_price": 14660
|
||||
},
|
||||
"041520": {
|
||||
"code": "041520",
|
||||
"name": "이엘씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 722,
|
||||
"entry_price": 12086.675900277009,
|
||||
"entry_at": "2026-09-10T09:00:55.814433+09:00",
|
||||
"stop": 10997,
|
||||
"target": 14266,
|
||||
"peak": 13200,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 233,744 도달",
|
||||
"cur_price": 248750,
|
||||
"entry_reason": "눌림목 지정가 12,003 도달",
|
||||
"cur_price": 12790,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3332473.3402132783,
|
||||
"last_add_price": 244500
|
||||
"tranche_value": 4373273.034451844,
|
||||
"last_add_price": 12290
|
||||
},
|
||||
"261520": {
|
||||
"code": "261520",
|
||||
"name": "이지스",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 1325,
|
||||
"entry_price": 5240.950943396227,
|
||||
"entry_at": "2026-09-15T10:28:40.300992+09:00",
|
||||
"stop": 4646,
|
||||
"target": 6431,
|
||||
"peak": 6040,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 5,108 도달",
|
||||
"cur_price": 5860,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3476292.7545329626,
|
||||
"last_add_price": 5390
|
||||
},
|
||||
"372170": {
|
||||
"code": "372170",
|
||||
"name": "윤성에프앤씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 268,
|
||||
"entry_price": 20782.089552238805,
|
||||
"entry_at": "2026-09-16T13:08:39.885461+09:00",
|
||||
"stop": 17965,
|
||||
"target": 26416,
|
||||
"peak": 25500,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 21,341 도달",
|
||||
"cur_price": 23800,
|
||||
"tranches": 2,
|
||||
"tranche_value": 2789435.574661536,
|
||||
"last_add_price": 21400
|
||||
},
|
||||
"307870": {
|
||||
"code": "307870",
|
||||
"name": "비투엔",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 5343,
|
||||
"entry_price": 1220.0830993823695,
|
||||
"entry_at": "2026-10-01T09:00:54.040849+09:00",
|
||||
"stop": 1066,
|
||||
"target": 1528,
|
||||
"peak": 1308,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 1,241 도달",
|
||||
"cur_price": 1297,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3260787.278232004,
|
||||
"last_add_price": 1243
|
||||
},
|
||||
"048910": {
|
||||
"code": "048910",
|
||||
"name": "대원미디어",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 1456,
|
||||
"entry_price": 7078.8873626373625,
|
||||
"entry_at": "2026-10-01T09:58:43.236173+09:00",
|
||||
"stop": 7732,
|
||||
"target": 8151,
|
||||
"peak": 8390,
|
||||
"trailing_on": true,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 8260,
|
||||
"tranches": 2,
|
||||
"tranche_value": 5159308.130815627,
|
||||
"last_add_price": 7170
|
||||
},
|
||||
"295310": {
|
||||
"code": "295310",
|
||||
"name": "에이치브이엠",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 58,
|
||||
"entry_price": 75200,
|
||||
"entry_at": "2026-10-07T09:06:54.037811+09:00",
|
||||
"stop": 67951,
|
||||
"target": 89698,
|
||||
"peak": 76900,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 74200,
|
||||
"tranches": 1,
|
||||
"tranche_value": 4370283.78095412,
|
||||
"last_add_price": 75200
|
||||
},
|
||||
"214450": {
|
||||
"code": "214450",
|
||||
"name": "파마리서치",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 16,
|
||||
"entry_price": 301000,
|
||||
"entry_at": "2026-10-08T09:02:53.413414+09:00",
|
||||
"stop": 276000,
|
||||
"target": 351000,
|
||||
"peak": 306000,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 303,967 도달",
|
||||
"cur_price": 302000,
|
||||
"tranches": 1,
|
||||
"tranche_value": 5056717.232835032,
|
||||
"last_add_price": 301000
|
||||
}
|
||||
},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100666534.975,
|
||||
"max_drawdown": 0.005230139292375102,
|
||||
"last_exit": {},
|
||||
"realized_pnl": -20150909.788250048,
|
||||
"closed_trades": 66,
|
||||
"wins": 9,
|
||||
"peak_equity": 101695667.93550003,
|
||||
"max_drawdown": 0.24548980547857824,
|
||||
"last_exit": {
|
||||
"030000": "2026-09-10",
|
||||
"035420": "2026-06-23",
|
||||
"403870": "2026-07-02",
|
||||
"095610": "2026-09-09",
|
||||
"093370": "2026-07-20",
|
||||
"240810": "2026-07-24",
|
||||
"009150": "2026-07-06",
|
||||
"034730": "2026-07-08",
|
||||
"214450": "2026-09-02",
|
||||
"003490": "2026-07-07",
|
||||
"319660": "2026-07-23",
|
||||
"105560": "2026-07-20",
|
||||
"086790": "2026-07-21",
|
||||
"003680": "2026-09-09",
|
||||
"252990": "2026-08-25",
|
||||
"010950": "2026-09-10",
|
||||
"090850": "2026-08-05",
|
||||
"161890": "2026-07-30",
|
||||
"078930": "2026-09-30",
|
||||
"138040": "2026-09-08",
|
||||
"218150": "2026-08-19",
|
||||
"194370": "2026-08-24",
|
||||
"068270": "2026-08-26",
|
||||
"012450": "2026-09-15",
|
||||
"060250": "2026-09-16",
|
||||
"004370": "2026-09-17",
|
||||
"316140": "2026-10-01",
|
||||
"257720": "2026-10-01",
|
||||
"300720": "2026-10-07",
|
||||
"327260": "2026-10-08"
|
||||
},
|
||||
"created_at": "2026-06-11T09:00:01.944270+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.282444+09:00"
|
||||
"updated_at": "2026-10-08T15:28:47.144334+09:00"
|
||||
}
|
||||
@@ -1,2 +1,185 @@
|
||||
{"ts": "2026-06-12T09:00:34.009437+09:00", "side": "BUY", "code": "035420", "name": "NAVER", "price": 232500, "qty": 14, "cost": 3255488, "path": "pullback", "reason": "눌림목 지정가 233,744 도달", "stop": 197616, "target": 302268, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "233,500 vs 224,820"}, {"label": "정배열(5>20)", "ok": true, "detail": "258,500 / 224,820"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "258,500 / 215,623"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+8.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -159 · 기 +1,154 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.99% (환산) vs 평균 2.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 304,000"}, {"label": "거래량 급증", "ok": false, "detail": "4,694,733 (환산) vs 평균 3,404,288"}, {"label": "회전율 급증", "ok": false, "detail": "2.99% (환산) vs 평균 2.17%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 233,744 (현재 233,500)"}]}
|
||||
{"ts": "2026-06-12T10:25:04.174047+09:00", "side": "BUY", "code": "035420", "name": "NAVER", "price": 244500, "qty": 13, "cost": 3178977, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 197616, "target": 302268, "signals": [{"label": "손절선", "ok": true, "detail": "197,616 (현재 244,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 302,268"}, {"label": "추세(20일선)", "ok": true, "detail": "225,370"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -116 · 기 +1,185"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 244,128"}]}
|
||||
{"ts": "2026-06-17T16:47:28.507097+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 62900, "qty": 47, "cost": 2956743, "path": "pullback", "reason": "눌림목 지정가 64,531 도달", "stop": 52447, "target": 83807, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "62,800 vs 52,858"}, {"label": "정배열(5>20)", "ok": true, "detail": "59,880 / 52,858"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "59,880 / 49,831"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -4,434 · 기 +5,040 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+9%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "11.31% vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "9,309,211 vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": true, "detail": "11.31% vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "58"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 64,531 (현재 62,800)"}]}
|
||||
{"ts": "2026-06-19T09:02:06.348139+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19160, "qty": 326, "cost": 6247097, "path": "pullback", "reason": "눌림목 지정가 20,044 도달", "stop": 18100, "target": 21279, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "19,150 vs 18,862"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,616 / 18,862"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,616 / 19,361"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-31.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,162 · 기 +2,080 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "37,673 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,044 (현재 19,150)"}]}
|
||||
{"ts": "2026-06-23T09:00:09.447545+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18820, "qty": 326, "proceeds": 6123356, "entry_price": 19160, "pnl": -123741, "pnl_pct": -1.77, "hold_from": "2026-06-19T09:02:06.348137+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "18,100 (현재 18,710)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,279"}, {"label": "추세(20일선)", "ok": false, "detail": "18,840"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -318 · 기 -227"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,528"}]}
|
||||
{"ts": "2026-06-23T15:10:02.424604+09:00", "side": "SELL", "code": "035420", "name": "NAVER", "price": 203000, "qty": 27, "proceeds": 5470312, "entry_price": 238278, "pnl": -964153, "pnl_pct": -14.81, "hold_from": "2026-06-12T09:00:34.009427+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "203,394 (현재 203,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 308,046"}, {"label": "추세(20일선)", "ok": false, "detail": "231,595"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +408 · 기 -163"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-06-24T09:04:06.114453+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 52300, "qty": 47, "proceeds": 2453307, "entry_price": 62900, "pnl": -503437, "pnl_pct": -16.85, "hold_from": "2026-06-17T16:47:28.507089+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "52,447 (현재 52,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 83,807"}, {"label": "추세(20일선)", "ok": false, "detail": "52,332"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +711 · 기 -2,060"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 66,717"}]}
|
||||
{"ts": "2026-06-24T09:04:06.114580+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 156700, "qty": 17, "cost": 2664300, "path": "pullback", "reason": "눌림목 지정가 165,075 도달", "stop": 129363, "target": 211374, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "156,200 vs 121,650"}, {"label": "정배열(5>20)", "ok": true, "detail": "143,260 / 121,650"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "143,260 / 121,703"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+46.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +143 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+6%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "5.83% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "2,860,073 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "5.83% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 165,075 (현재 156,200)"}]}
|
||||
{"ts": "2026-06-24T09:12:05.349711+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 17270, "qty": 199, "cost": 3437246, "path": "pullback", "reason": "눌림목 지정가 17,366 도달", "stop": 14804, "target": 22203, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "17,280 vs 12,914"}, {"label": "정배열(5>20)", "ok": true, "detail": "16,364 / 12,914"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "16,364 / 11,500"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +1,740 · 기 -508 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+16%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.34% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": false, "detail": "2,509,340 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": false, "detail": "2.34% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "65"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 17,366 (현재 17,280)"}]}
|
||||
{"ts": "2026-06-24T09:46:04.781319+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158200, "qty": 17, "cost": 2689803, "path": "pullback", "reason": "눌림목 지정가 158,264 도달", "stop": 130097, "target": 214407, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "158,000 vs 136,315"}, {"label": "정배열(5>20)", "ok": true, "detail": "172,240 / 136,315"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "172,240 / 99,750"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+56.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +41 · 기 +65 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+5%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.25% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "822,940 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "4.25% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,264 (현재 158,000)"}]}
|
||||
{"ts": "2026-06-24T11:12:05.898443+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 18260, "qty": 188, "cost": 3433395, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 14804, "target": 22203, "signals": [{"label": "손절선", "ok": true, "detail": "14,804 (현재 18,250)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,203"}, {"label": "추세(20일선)", "ok": true, "detail": "12,962"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +1,742 · 기 -508"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 18,094"}]}
|
||||
{"ts": "2026-06-25T09:00:23.578154+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 179100, "qty": 15, "cost": 2686903, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 130097, "target": 214407, "signals": [{"label": "손절선", "ok": true, "detail": "130,097 (현재 178,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 214,407"}, {"label": "추세(20일선)", "ok": true, "detail": "137,330"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -56 · 기 +134"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 167,275"}]}
|
||||
{"ts": "2026-06-25T09:02:04.071867+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 53200, "qty": 49, "cost": 2607191, "path": "pullback", "reason": "눌림목 지정가 63,881 도달", "stop": 43200, "target": 73200, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "53,300 vs 52,382"}, {"label": "정배열(5>20)", "ok": true, "detail": "57,980 / 52,382"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "57,980 / 49,672"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+15.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +406 · 기 -1,461 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+29%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.21% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "1,819,047 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "2.21% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 63,881 (현재 53,300)"}]}
|
||||
{"ts": "2026-06-26T09:00:05.688310+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 50000, "qty": 49, "proceeds": 2445222, "entry_price": 53200, "pnl": -161969, "pnl_pct": -6.02, "hold_from": "2026-06-25T09:02:04.071865+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "43,200 (현재 51,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 73,200"}, {"label": "추세(20일선)", "ok": false, "detail": "52,268"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -311 · 기 -267"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 57,056"}]}
|
||||
{"ts": "2026-06-26T10:14:04.059906+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 166900, "qty": 16, "cost": 2670801, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 129363, "target": 211374, "signals": [{"label": "손절선", "ok": true, "detail": "129,363 (현재 166,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 211,374"}, {"label": "추세(20일선)", "ok": true, "detail": "122,175"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -126 · 기 +320"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 165,659"}]}
|
||||
{"ts": "2026-06-29T11:36:01.264617+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 745000, "qty": 4, "cost": 2980447, "path": "pullback", "reason": "눌림목 지정가 748,914 도달", "stop": 645871, "target": 943257, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "744,000 vs 676,650"}, {"label": "정배열(5>20)", "ok": true, "detail": "766,800 / 676,650"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "766,800 / 530,600"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -194 · 기 +152 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+8%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.35% (환산) vs 평균 0.45% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 883,000"}, {"label": "거래량 급증", "ok": true, "detail": "977,966 (환산) vs 평균 329,863"}, {"label": "회전율 급증", "ok": true, "detail": "1.35% (환산) vs 평균 0.45%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 748,914 (현재 744,000)"}]}
|
||||
{"ts": "2026-06-29T14:18:01.484808+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 778000, "qty": 4, "cost": 3112467, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 645871, "target": 943257, "signals": [{"label": "손절선", "ok": true, "detail": "645,871 (현재 779,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 943,257"}, {"label": "추세(20일선)", "ok": true, "detail": "678,400"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -200 · 기 +143"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 778,043"}]}
|
||||
{"ts": "2026-06-30T09:00:05.639912+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 55300, "qty": 43, "cost": 2378257, "path": "pullback", "reason": "눌림목 지정가 63,981 도달", "stop": 44022, "target": 77857, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "53,700 vs 52,402"}, {"label": "정배열(5>20)", "ok": true, "detail": "58,060 / 52,402"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "58,060 / 49,679"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +222 · 기 -821 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+28%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "827 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 63,981 (현재 53,700)"}]}
|
||||
{"ts": "2026-07-01T09:06:03.209135+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 357500, "qty": 15, "cost": 5363304, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 325040, "target": 422420, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "357,000 vs 296,975"}, {"label": "정배열(5>20)", "ok": true, "detail": "317,600 / 296,975"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "317,600 / 305,700"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -75 · 기 +221 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+35%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.89% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "196,247 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.89% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "68"}]}
|
||||
{"ts": "2026-07-01T09:24:02.309730+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 372000, "qty": 14, "cost": 5208781, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 325040, "target": 422420, "signals": [{"label": "손절선", "ok": true, "detail": "325,040 (현재 372,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 422,420"}, {"label": "추세(20일선)", "ok": true, "detail": "297,725"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -75 · 기 +221"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 368,499"}]}
|
||||
{"ts": "2026-07-01T11:06:02.351611+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 212000, "qty": 32, "proceeds": 6770771, "entry_price": 167997, "pnl": 1394065, "pnl_pct": 26.19, "hold_from": "2026-06-24T09:46:04.781317+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "213,193 (현재 213,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "139,065"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -229 · 기 +300"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T10:18:03.830979+09:00", "side": "BUY", "code": "009150", "name": "삼성전기", "price": 1985000, "qty": 3, "cost": 5955893, "path": "pullback", "reason": "눌림목 지정가 1,994,011 도달", "stop": 1901000, "target": 2153000, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "1,982,000 vs 1,967,800"}, {"label": "정배열(5>20)", "ok": true, "detail": "2,038,800 / 1,967,800"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "2,038,800 / 1,295,483"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +337 · 기 +193 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+6%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.19% (환산) vs 평균 1.76% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 2,417,000"}, {"label": "거래량 급증", "ok": true, "detail": "3,133,469 (환산) vs 평균 1,312,239"}, {"label": "회전율 급증", "ok": true, "detail": "4.19% (환산) vs 평균 1.76%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 1,994,011 (현재 1,982,000)"}]}
|
||||
{"ts": "2026-07-02T10:34:04.117399+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18860, "qty": 324, "cost": 6111557, "path": "pullback", "reason": "눌림목 지정가 19,970 도달", "stop": 17760, "target": 21060, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "18,860 vs 18,848"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,558 / 18,848"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,558 / 19,356"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -339 · 기 +110 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+35%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.80% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "924,985 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.80% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "48"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,970 (현재 18,860)"}]}
|
||||
{"ts": "2026-07-02T11:20:02.053948+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 22550, "qty": 271, "cost": 6111967, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17760, "target": 21060, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 19,350)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,060"}, {"label": "추세(20일선)", "ok": true, "detail": "18,872"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,250"}]}
|
||||
{"ts": "2026-07-02T11:22:01.962941+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 19000, "qty": 595, "proceeds": 11282955, "entry_price": 20541, "pnl": -940568, "pnl_pct": -7.5, "hold_from": "2026-07-02T10:34:04.117397+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "19,371 (현재 19,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,881"}, {"label": "추세(20일선)", "ok": true, "detail": "18,860"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T12:42:02.217684+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 15250, "qty": 387, "proceeds": 5890242, "entry_price": 17751, "pnl": -980399, "pnl_pct": -14.09, "hold_from": "2026-06-24T09:12:05.349710+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "15,280 (현재 15,280)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,692"}, {"label": "추세(20일선)", "ok": true, "detail": "12,814"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,920 · 기 +434"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T14:28:02.056486+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 134800, "qty": 33, "proceeds": 4439726, "entry_price": 161645, "pnl": -895375, "pnl_pct": -16.61, "hold_from": "2026-06-24T09:04:06.114579+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "134,769 (현재 134,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 215,398"}, {"label": "추세(20일선)", "ok": true, "detail": "120,575"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +395 · 기 -1,516"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T15:04:06.183065+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 43900, "qty": 43, "proceeds": 1884019, "entry_price": 55300, "pnl": -494238, "pnl_pct": -20.61, "hold_from": "2026-06-30T09:00:05.639905+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "44,022 (현재 43,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 77,857"}, {"label": "추세(20일선)", "ok": false, "detail": "51,912"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +2,379 · 기 -6,625"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 59,411"}]}
|
||||
{"ts": "2026-07-03T09:00:08.430585+09:00", "side": "SELL", "code": "009150", "name": "삼성전기", "price": 1895000, "qty": 3, "proceeds": 5673914, "entry_price": 1985000, "pnl": -281979, "pnl_pct": -4.53, "hold_from": "2026-07-02T10:18:03.830976+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "1,901,000 (현재 1,894,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 2,153,000"}, {"label": "추세(20일선)", "ok": false, "detail": "1,963,400"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +132 · 기 +129"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 2,084,495"}]}
|
||||
{"ts": "2026-07-03T09:02:04.917379+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 133400, "qty": 16, "cost": 2134720, "path": "pullback", "reason": "눌림목 지정가 163,875 도달", "stop": 104263, "target": 191674, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "133,300 vs 120,505"}, {"label": "정배열(5>20)", "ok": true, "detail": "138,680 / 120,505"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "138,680 / 121,322"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+18.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +674 · 기 -2,170 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+31%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.16% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": false, "detail": "571,607 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": false, "detail": "1.16% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 163,875 (현재 133,300)"}]}
|
||||
{"ts": "2026-07-03T09:02:04.917552+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 14660, "qty": 173, "cost": 2536560, "path": "pullback", "reason": "눌림목 지정가 17,179 도달", "stop": 11914, "target": 20152, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "14,680 vs 12,784"}, {"label": "정배열(5>20)", "ok": true, "detail": "15,844 / 12,784"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,844 / 11,456"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,591 · 기 +253 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+36%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.92% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": false, "detail": "991,593 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": false, "detail": "0.92% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 17,179 (현재 14,680)"}]}
|
||||
{"ts": "2026-07-03T09:10:03.668540+09:00", "side": "SELL", "code": "034730", "name": "SK", "price": 656000, "qty": 8, "proceeds": 5237766, "entry_price": 761500, "pnl": -855147, "pnl_pct": -13.85, "hold_from": "2026-06-29T11:36:01.264615+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "662,371 (현재 657,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 959,757"}, {"label": "추세(20일선)", "ok": false, "detail": "672,300"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -51 · 기 +3"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T09:50:01.665084+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 328500, "qty": 29, "proceeds": 9507923, "entry_price": 364500, "pnl": -1064162, "pnl_pct": -9.88, "hold_from": "2026-07-01T09:06:03.209133+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "331,504 (현재 331,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 430,491"}, {"label": "추세(20일선)", "ok": true, "detail": "295,675"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -127 · 기 +294"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T10:24:02.770189+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19040, "qty": 308, "cost": 5865200, "path": "pullback", "reason": "눌림목 지정가 20,004 도달", "stop": 17921, "target": 21277, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "19,030 vs 18,856"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,592 / 18,856"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,592 / 19,359"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -35 · 기 +168 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.54% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "619,193 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.54% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,004 (현재 19,030)"}]}
|
||||
{"ts": "2026-07-06T09:00:08.148622+09:00", "side": "BUY", "code": "009150", "name": "삼성전기", "price": 1993000, "qty": 2, "cost": 3986598, "path": "pullback", "reason": "눌림목 지정가 1,995,439 도달", "stop": 1901000, "target": 2177000, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "1,975,000 vs 1,967,450"}, {"label": "정배열(5>20)", "ok": true, "detail": "2,037,400 / 1,967,450"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "2,037,400 / 1,295,367"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+9.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +151 · 기 +57 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+9%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.82% (환산) vs 평균 1.76% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 2,417,000"}, {"label": "거래량 급증", "ok": false, "detail": "615,080 (환산) vs 평균 1,312,239"}, {"label": "회전율 급증", "ok": false, "detail": "0.82% (환산) vs 평균 1.76%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 1,995,439 (현재 1,975,000)"}]}
|
||||
{"ts": "2026-07-06T09:00:08.151514+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 690000, "qty": 5, "cost": 3450518, "path": "pullback", "reason": "눌림목 지정가 747,129 도달", "stop": 604000, "target": 862000, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "695,000 vs 674,200"}, {"label": "정배열(5>20)", "ok": true, "detail": "757,000 / 674,200"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "757,000 / 529,783"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -81 · 기 +57 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+21%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.17% (환산) vs 평균 0.45% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 883,000"}, {"label": "거래량 급증", "ok": false, "detail": "124,240 (환산) vs 평균 329,863"}, {"label": "회전율 급증", "ok": false, "detail": "0.17% (환산) vs 평균 0.45%"}, {"label": "RSI", "ok": true, "detail": "54"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 747,129 (현재 695,000)"}]}
|
||||
{"ts": "2026-07-06T09:34:02.056068+09:00", "side": "BUY", "code": "003490", "name": "대한항공", "price": 29100, "qty": 200, "cost": 5820873, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 27052, "target": 33195, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "29,100 vs 25,960"}, {"label": "정배열(5>20)", "ok": true, "detail": "26,160 / 25,960"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "26,160 / 25,220"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-3.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -142 · 기 +2,052 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.9/5"}, {"label": "상승여력", "ok": true, "detail": "+19%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.31% (환산) vs 평균 0.58% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 28,400"}, {"label": "거래량 급증", "ok": true, "detail": "4,788,480 (환산) vs 평균 2,146,231"}, {"label": "회전율 급증", "ok": true, "detail": "1.31% (환산) vs 평균 0.58%"}, {"label": "RSI", "ok": true, "detail": "65"}]}
|
||||
{"ts": "2026-07-06T10:06:01.980443+09:00", "side": "SELL", "code": "009150", "name": "삼성전기", "price": 1900000, "qty": 2, "proceeds": 3792590, "entry_price": 1993000, "pnl": -194008, "pnl_pct": -4.67, "hold_from": "2026-07-06T09:00:08.148616+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "1,901,000 (현재 1,901,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 2,177,000"}, {"label": "추세(20일선)", "ok": false, "detail": "1,963,750"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +135 · 기 +46"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 2,089,780"}]}
|
||||
{"ts": "2026-07-07T09:12:07.264150+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 155900, "qty": 16, "cost": 2494774, "path": "pullback", "reason": "눌림목 지정가 158,314 도달", "stop": 127872, "target": 211957, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "155,800 vs 136,205"}, {"label": "정배열(5>20)", "ok": true, "detail": "171,800 / 136,205"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,800 / 99,713"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+43.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +80 · 기 -197 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+14%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.33% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "645,567 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "3.33% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,314 (현재 155,800)"}]}
|
||||
{"ts": "2026-07-07T12:02:08.178098+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 12230, "qty": 173, "proceeds": 2111664, "entry_price": 14660, "pnl": -424896, "pnl_pct": -16.58, "hold_from": "2026-07-03T09:02:04.917551+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "11,914 (현재 12,230)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,152"}, {"label": "추세(20일선)", "ok": false, "detail": "12,661"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -1,558 · 기 -4"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 15,662"}]}
|
||||
{"ts": "2026-07-07T14:08:24.985266+09:00", "side": "SELL", "code": "003490", "name": "대한항공", "price": 26600, "qty": 200, "proceeds": 5309626, "entry_price": 29100, "pnl": -511247, "pnl_pct": -8.59, "hold_from": "2026-07-06T09:34:02.056065+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "27,052 (현재 26,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 33,195"}, {"label": "추세(20일선)", "ok": true, "detail": "25,870"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -532 · 기 +3,040"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 29,724"}]}
|
||||
{"ts": "2026-07-07T14:08:27.625222+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 183300, "qty": 11, "cost": 2016602, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 104263, "target": 191674, "signals": [{"label": "손절선", "ok": true, "detail": "104,263 (현재 183,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 191,674"}, {"label": "추세(20일선)", "ok": true, "detail": "120,210"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +263 · 기 -1,543"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 141,825"}]}
|
||||
{"ts": "2026-07-07T14:08:27.626449+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 177000, "qty": 14, "cost": 2478372, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 127872, "target": 211957, "signals": [{"label": "손절선", "ok": true, "detail": "127,872 (현재 177,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 211,957"}, {"label": "추세(20일선)", "ok": true, "detail": "134,320"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +152 · 기 -281"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 165,142"}]}
|
||||
{"ts": "2026-07-07T14:08:30.307609+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 20750, "qty": 282, "cost": 5852378, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17921, "target": 21277, "signals": [{"label": "손절선", "ok": true, "detail": "17,921 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,277"}, {"label": "추세(20일선)", "ok": true, "detail": "18,884"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 +113"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,358"}]}
|
||||
{"ts": "2026-07-07T14:08:30.308497+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 815000, "qty": 4, "cost": 3260489, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 604000, "target": 862000, "signals": [{"label": "손절선", "ok": true, "detail": "604,000 (현재 815,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 862,000"}, {"label": "추세(20일선)", "ok": true, "detail": "673,250"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -44 · 기 +3"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 721,777"}]}
|
||||
{"ts": "2026-07-08T09:00:17.141650+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 108300, "qty": 27, "proceeds": 2918398, "entry_price": 153730, "pnl": -1232925, "pnl_pct": -29.55, "hold_from": "2026-07-03T09:02:04.917377+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "128,455 (현재 112,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 204,279"}, {"label": "추세(20일선)", "ok": false, "detail": "119,475"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +243 · 기 -857"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-08T09:00:17.142747+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 137900, "qty": 30, "proceeds": 4128933, "entry_price": 165747, "pnl": -844213, "pnl_pct": -16.8, "hold_from": "2026-07-07T09:12:07.264149+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "138,020 (현재 137,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 221,200"}, {"label": "추세(20일선)", "ok": true, "detail": "135,310"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +458 · 기 -572"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-08T09:00:17.143299+09:00", "side": "SELL", "code": "034730", "name": "SK", "price": 602000, "qty": 9, "proceeds": 5407435, "entry_price": 745556, "pnl": -1303572, "pnl_pct": -19.25, "hold_from": "2026-07-06T09:00:08.151507+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "650,225 (현재 602,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 936,217"}, {"label": "추세(20일선)", "ok": false, "detail": "669,550"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +49 · 기 -19"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-08T09:12:05.961489+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18900, "qty": 590, "proceeds": 11129256, "entry_price": 19857, "pnl": -588322, "pnl_pct": -4.82, "hold_from": "2026-07-03T10:24:02.770188+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "18,903 (현재 18,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,766"}, {"label": "추세(20일선)", "ok": true, "detail": "18,850"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +193 · 기 +193"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-08T09:48:01.868099+09:00", "side": "BUY", "code": "319660", "name": "피에스케이", "price": 161100, "qty": 34, "cost": 5478222, "path": "pullback", "reason": "눌림목 지정가 168,676 도달", "stop": 158000, "target": 167300, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "161,600 vs 149,870"}, {"label": "정배열(5>20)", "ok": true, "detail": "169,960 / 149,870"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "169,960 / 113,252"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+77.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +198 · 기 -309 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+11%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.83% (환산) vs 평균 2.68% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 196,500"}, {"label": "거래량 급증", "ok": false, "detail": "1,110,233 (환산) vs 평균 777,376"}, {"label": "회전율 급증", "ok": false, "detail": "3.83% (환산) vs 평균 2.68%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 168,676 (현재 161,600)"}]}
|
||||
{"ts": "2026-07-08T10:26:01.962983+09:00", "side": "SELL", "code": "319660", "name": "피에스케이", "price": 157600, "qty": 34, "proceeds": 5347951, "entry_price": 161100, "pnl": -130270, "pnl_pct": -2.17, "hold_from": "2026-07-08T09:48:01.868097+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "158,000 (현재 158,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 167,300"}, {"label": "추세(20일선)", "ok": true, "detail": "149,690"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +198 · 기 -312"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 170,862"}]}
|
||||
{"ts": "2026-07-09T09:02:04.457277+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 144400, "qty": 14, "cost": 2021903, "path": "pullback", "reason": "눌림목 지정가 156,479 도달", "stop": 113618, "target": 205964, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "144,400 vs 135,635"}, {"label": "정배열(5>20)", "ok": true, "detail": "169,520 / 135,635"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "169,520 / 99,523"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+42.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +503 · 기 -501 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+23%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.94% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "569,187 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "2.94% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 156,479 (현재 144,400)"}]}
|
||||
{"ts": "2026-07-09T09:02:04.457421+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19110, "qty": 290, "cost": 5542731, "path": "pullback", "reason": "눌림목 지정가 20,021 도달", "stop": 18017, "target": 21296, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "19,070 vs 18,858"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,600 / 18,858"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,600 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+0.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -10 · 기 +164 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.08% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "93,540 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.08% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,021 (현재 19,070)"}]}
|
||||
{"ts": "2026-07-09T09:02:04.457549+09:00", "side": "BUY", "code": "319660", "name": "피에스케이", "price": 158300, "qty": 35, "cost": 5541331, "path": "pullback", "reason": "눌림목 지정가 168,455 도달", "stop": 158000, "target": 158900, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "158,200 vs 149,700"}, {"label": "정배열(5>20)", "ok": true, "detail": "169,280 / 149,700"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "169,280 / 113,195"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+81.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +296 · 기 -306 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.72% (환산) vs 평균 2.68% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 196,500"}, {"label": "거래량 급증", "ok": false, "detail": "499,233 (환산) vs 평균 777,376"}, {"label": "회전율 급증", "ok": false, "detail": "1.72% (환산) vs 평균 2.68%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 168,455 (현재 158,200)"}]}
|
||||
{"ts": "2026-07-09T09:12:02.503058+09:00", "side": "SELL", "code": "319660", "name": "피에스케이", "price": 156900, "qty": 35, "proceeds": 5480792, "entry_price": 158300, "pnl": -60540, "pnl_pct": -0.88, "hold_from": "2026-07-09T09:02:04.457547+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "158,000 (현재 156,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "149,635"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +296 · 기 -306"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-09T09:36:03.452342+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 132000, "qty": 39, "cost": 5148772, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 120761, "target": 154477, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "131,900 vs 108,920"}, {"label": "정배열(5>20)", "ok": true, "detail": "115,560 / 108,920"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,560 / 113,228"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+22.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -184 · 기 +964 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.91% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "1,026,867 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.91% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-10T09:00:10.854533+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158000, "qty": 13, "cost": 2054308, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 113618, "target": 205964, "signals": [{"label": "손절선", "ok": true, "detail": "113,618 (현재 157,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 205,964"}, {"label": "추세(20일선)", "ok": true, "detail": "136,265"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +452 · 기 -360"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 154,000"}]}
|
||||
{"ts": "2026-07-10T09:00:10.855604+09:00", "side": "BUY", "code": "319660", "name": "피에스케이", "price": 164000, "qty": 33, "cost": 5412812, "path": "pullback", "reason": "눌림목 지정가 169,105 도달", "stop": 158000, "target": 176000, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "162,200 vs 149,900"}, {"label": "정배열(5>20)", "ok": true, "detail": "170,080 / 149,900"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "170,080 / 113,262"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+85.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +225 · 기 -178 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+10%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.58% (환산) vs 평균 2.68% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 196,500"}, {"label": "거래량 급증", "ok": false, "detail": "746,107 (환산) vs 평균 777,376"}, {"label": "회전율 급증", "ok": false, "detail": "2.58% (환산) vs 평균 2.68%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 169,105 (현재 162,200)"}]}
|
||||
{"ts": "2026-07-10T09:28:02.614093+09:00", "side": "BUY", "code": "105560", "name": "KB금융", "price": 185900, "qty": 29, "cost": 5391909, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 171311, "target": 215077, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "185,600 vs 159,890"}, {"label": "정배열(5>20)", "ok": true, "detail": "159,900 / 159,890"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "159,900 / 157,368"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+26.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,310 · 기 +1,527 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+16%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.06% (환산) vs 평균 0.46% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 182,700"}, {"label": "거래량 급증", "ok": true, "detail": "3,715,113 (환산) vs 평균 1,640,325"}, {"label": "회전율 급증", "ok": true, "detail": "1.06% (환산) vs 평균 0.46%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-10T09:46:11.469025+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 12830, "qty": 179, "cost": 2296914, "path": "pullback", "reason": "눌림목 지정가 16,984 도달", "stop": 10360, "target": 17770, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "12,820 vs 12,690"}, {"label": "정배열(5>20)", "ok": true, "detail": "15,472 / 12,690"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,472 / 11,425"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+1.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +228 · 기 +229 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+56%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "9.75% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": true, "detail": "10,460,473 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": true, "detail": "9.75% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 16,984 (현재 12,820)"}]}
|
||||
{"ts": "2026-07-10T10:14:04.432989+09:00", "side": "BUY", "code": "319660", "name": "피에스케이", "price": 173600, "qty": 32, "cost": 5556033, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 158000, "target": 176000, "signals": [{"label": "손절선", "ok": true, "detail": "158,000 (현재 173,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 176,000"}, {"label": "추세(20일선)", "ok": true, "detail": "150,470"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +205 · 기 -155"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 173,548"}]}
|
||||
{"ts": "2026-07-10T11:16:02.469865+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 13850, "qty": 166, "cost": 2299445, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10360, "target": 17770, "signals": [{"label": "손절선", "ok": true, "detail": "10,360 (현재 13,850)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,770"}, {"label": "추세(20일선)", "ok": true, "detail": "12,742"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +210 · 기 +239"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,843"}]}
|
||||
{"ts": "2026-07-10T12:14:02.666464+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 126200, "qty": 15, "cost": 1893284, "path": "pullback", "reason": "눌림목 지정가 162,268 도달", "stop": 96000, "target": 186600, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "126,200 vs 120,150"}, {"label": "정배열(5>20)", "ok": true, "detail": "137,260 / 120,150"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "137,260 / 121,203"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +483 · 기 -241 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "7.53% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "3,695,762 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "7.53% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 162,268 (현재 126,200)"}]}
|
||||
{"ts": "2026-07-13T09:00:09.888217+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 136900, "qty": 38, "cost": 5202980, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 120761, "target": 154477, "signals": [{"label": "손절선", "ok": true, "detail": "120,761 (현재 136,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,477"}, {"label": "추세(20일선)", "ok": true, "detail": "109,140"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -64 · 기 +919"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,864"}]}
|
||||
{"ts": "2026-07-13T09:12:04.294393+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 341500, "qty": 15, "cost": 5123268, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 310647, "target": 403206, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "341,000 vs 296,175"}, {"label": "정배열(5>20)", "ok": true, "detail": "314,400 / 296,175"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "314,400 / 305,433"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -63 · 기 +144 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.90% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "197,687 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.90% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "64"}]}
|
||||
{"ts": "2026-07-13T09:26:03.405300+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 139100, "qty": 13, "cost": 1808571, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 96000, "target": 186600, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 139,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 186,600"}, {"label": "추세(20일선)", "ok": true, "detail": "120,795"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -213 · 기 +772"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 136,162"}]}
|
||||
{"ts": "2026-07-13T09:36:02.268298+09:00", "side": "BUY", "code": "105560", "name": "KB금융", "price": 191000, "qty": 29, "cost": 5539831, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 171311, "target": 215077, "signals": [{"label": "손절선", "ok": true, "detail": "171,311 (현재 191,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 215,077"}, {"label": "추세(20일선)", "ok": true, "detail": "160,160"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -867 · 기 +1,393"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 190,952"}]}
|
||||
{"ts": "2026-07-13T12:44:04.349246+09:00", "side": "SELL", "code": "319660", "name": "피에스케이", "price": 187900, "qty": 65, "proceeds": 12189684, "entry_price": 168726, "pnl": 1220839, "pnl_pct": 11.36, "hold_from": "2026-07-10T09:00:10.855598+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "189,133 (현재 187,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "151,185"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -254 · 기 +457"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-14T10:20:06.141551+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 310500, "qty": 15, "proceeds": 4648418, "entry_price": 341500, "pnl": -474851, "pnl_pct": -9.08, "hold_from": "2026-07-13T09:12:04.294392+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "310,647 (현재 310,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 403,206"}, {"label": "추세(20일선)", "ok": true, "detail": "294,650"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -50 · 기 +82"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 351,713"}]}
|
||||
{"ts": "2026-07-16T09:00:08.249132+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 209000, "qty": 27, "proceeds": 5631996, "entry_price": 150948, "pnl": 1555785, "pnl_pct": 38.46, "hold_from": "2026-07-09T09:02:04.457275+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "210,764 (현재 207,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "138,765"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -799 · 기 +891"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-16T09:00:08.249940+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18580, "qty": 290, "proceeds": 5377693, "entry_price": 19110, "pnl": -165038, "pnl_pct": -2.77, "hold_from": "2026-07-09T09:02:04.457419+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "18,017 (현재 18,560)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,296"}, {"label": "추세(20일선)", "ok": false, "detail": "18,832"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -120 · 기 -150"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,486"}]}
|
||||
{"ts": "2026-07-16T09:38:02.508846+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 138900, "qty": 40, "cost": 5556833, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 128529, "target": 159642, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "138,800 vs 119,185"}, {"label": "정배열(5>20)", "ok": true, "detail": "124,360 / 119,185"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "124,360 / 118,712"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+40.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +71 · 기 +417 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.76% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": true, "detail": "2,097,713 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": true, "detail": "0.76% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-07-20T09:04:02.871730+09:00", "side": "SELL", "code": "105560", "name": "KB금융", "price": 173000, "qty": 58, "proceeds": 10014434, "entry_price": 188450, "pnl": -917306, "pnl_pct": -8.2, "hold_from": "2026-07-10T09:28:02.614091+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "173,293 (현재 173,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 218,763"}, {"label": "추세(20일선)", "ok": true, "detail": "159,270"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -332 · 기 +291"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-20T12:28:02.310171+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 10310, "qty": 345, "proceeds": 3550014, "entry_price": 13321, "pnl": -1046345, "pnl_pct": -22.6, "hold_from": "2026-07-10T09:46:11.469023+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "10,360 (현재 10,310)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 19,242"}, {"label": "추세(20일선)", "ok": false, "detail": "14,755"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -887 · 기 +515"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-21T09:06:03.147127+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 127800, "qty": 40, "proceeds": 5102032, "entry_price": 138900, "pnl": -454802, "pnl_pct": -7.99, "hold_from": "2026-07-16T09:38:02.508844+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "128,529 (현재 127,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 159,642"}, {"label": "추세(20일선)", "ok": true, "detail": "118,640"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +121 · 기 +190"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 142,068"}]}
|
||||
{"ts": "2026-07-21T10:12:01.040324+09:00", "side": "BUY", "code": "319660", "name": "피에스케이", "price": 167000, "qty": 33, "cost": 5511827, "path": "pullback", "reason": "눌림목 지정가 169,640 도달", "stop": 158000, "target": 185000, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "167,000 vs 150,140"}, {"label": "정배열(5>20)", "ok": true, "detail": "171,040 / 150,140"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,040 / 113,342"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+92.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -534 · 기 +316 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+7%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.29% (환산) vs 평균 2.68% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 196,500"}, {"label": "거래량 급증", "ok": false, "detail": "951,819 (환산) vs 평균 777,376"}, {"label": "회전율 급증", "ok": false, "detail": "3.29% (환산) vs 평균 2.68%"}, {"label": "RSI", "ok": true, "detail": "58"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 169,640 (현재 167,000)"}]}
|
||||
{"ts": "2026-07-21T12:06:01.164333+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 344500, "qty": 14, "cost": 4823723, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 313808, "target": 405884, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "344,500 vs 296,350"}, {"label": "정배열(5>20)", "ok": true, "detail": "315,100 / 296,350"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "315,100 / 305,492"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+39.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -7 · 기 +1 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+43%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.38% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "247,158 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "2.38% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "65"}]}
|
||||
{"ts": "2026-07-22T09:00:06.709714+09:00", "side": "BUY", "code": "319660", "name": "피에스케이", "price": 177600, "qty": 31, "cost": 5506426, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 158000, "target": 185000, "signals": [{"label": "손절선", "ok": true, "detail": "158,000 (현재 178,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 185,000"}, {"label": "추세(20일선)", "ok": true, "detail": "150,715"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -377 · 기 +151"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 175,894"}]}
|
||||
{"ts": "2026-07-22T09:04:01.813458+09:00", "side": "BUY", "code": "161890", "name": "한국콜마", "price": 99200, "qty": 56, "cost": 5556033, "path": "pullback", "reason": "눌림목 지정가 98,848 도달", "stop": 92100, "target": 113400, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "98,800 vs 98,505"}, {"label": "정배열(5>20)", "ok": true, "detail": "107,060 / 98,505"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "107,060 / 91,913"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +29 · 기 -34 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.16% (환산) vs 평균 1.76% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 124,600"}, {"label": "거래량 급증", "ok": false, "detail": "272,433 (환산) vs 평균 415,172"}, {"label": "회전율 급증", "ok": false, "detail": "1.16% (환산) vs 평균 1.76%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 98,848 (현재 98,800)"}]}
|
||||
{"ts": "2026-07-22T12:12:00.734253+09:00", "side": "SELL", "code": "319660", "name": "피에스케이", "price": 157300, "qty": 64, "proceeds": 10047569, "entry_price": 172134, "pnl": -970684, "pnl_pct": -8.62, "hold_from": "2026-07-21T10:12:01.040322+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "158,000 (현재 157,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 200,403"}, {"label": "추세(20일선)", "ok": true, "detail": "149,655"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -407 · 기 +141"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-22T14:18:05.419864+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14030, "qty": 197, "cost": 2764325, "path": "pullback", "reason": "눌림목 지정가 14,023 도달", "stop": 11815, "target": 18460, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "14,020 vs 12,776"}, {"label": "정배열(5>20)", "ok": true, "detail": "14,552 / 12,776"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,552 / 13,283"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,005 · 기 +1,723 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.97% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "585,754 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.97% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "53"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 14,023 (현재 14,020)"}]}
|
||||
{"ts": "2026-07-22T15:04:00.990876+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158100, "qty": 15, "cost": 2371856, "path": "pullback", "reason": "눌림목 지정가 158,107 도달", "stop": 129761, "target": 214778, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "158,000 vs 136,315"}, {"label": "정배열(5>20)", "ok": true, "detail": "172,240 / 136,315"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "172,240 / 99,750"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+49.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -311 · 기 +22 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+24%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "5.02% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "971,360 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": true, "detail": "5.02% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,107 (현재 158,000)"}]}
|
||||
{"ts": "2026-07-23T09:00:22.456369+09:00", "side": "BUY", "code": "319660", "name": "피에스케이", "price": 160100, "qty": 34, "cost": 5444217, "path": "pullback", "reason": "눌림목 지정가 169,062 도달", "stop": 158000, "target": 164300, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "158,100 vs 149,695"}, {"label": "정배열(5>20)", "ok": true, "detail": "169,260 / 149,695"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "169,260 / 113,193"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+76.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -162 · 기 +37 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.05% (환산) vs 평균 2.68% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 196,500"}, {"label": "거래량 급증", "ok": false, "detail": "303,687 (환산) vs 평균 777,376"}, {"label": "회전율 급증", "ok": false, "detail": "1.05% (환산) vs 평균 2.68%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 169,062 (현재 158,100)"}]}
|
||||
{"ts": "2026-07-23T09:32:04.406594+09:00", "side": "SELL", "code": "319660", "name": "피에스케이", "price": 157800, "qty": 34, "proceeds": 5354738, "entry_price": 160100, "pnl": -89479, "pnl_pct": -1.44, "hold_from": "2026-07-23T09:00:22.456363+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "158,000 (현재 157,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "149,685"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -162 · 기 +37"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-23T09:44:04.277794+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18850, "qty": 290, "cost": 5467320, "path": "pullback", "reason": "눌림목 지정가 20,001 도달", "stop": 17760, "target": 21030, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "18,850 vs 18,847"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,556 / 18,847"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,556 / 19,356"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+13.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -25 · 기 +6 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.34% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "388,767 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.34% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "48"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,001 (현재 18,850)"}]}
|
||||
{"ts": "2026-07-23T09:50:01.509013+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 88200, "qty": 54, "cost": 4763514, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 80223, "target": 104155, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "88,200 vs 75,070"}, {"label": "정배열(5>20)", "ok": true, "detail": "83,000 / 75,070"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,000 / 75,058"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+47.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -150 · 기 +299 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.85% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "785,587 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.85% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-07-23T12:18:01.539249+09:00", "side": "BUY", "code": "161890", "name": "한국콜마", "price": 103100, "qty": 54, "cost": 5568235, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 92100, "target": 113400, "signals": [{"label": "손절선", "ok": true, "detail": "92,100 (현재 103,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 113,400"}, {"label": "추세(20일선)", "ok": true, "detail": "98,720"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +222 · 기 -227"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 103,029"}]}
|
||||
{"ts": "2026-07-23T12:22:02.589476+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 356000, "qty": 13, "cost": 4628694, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 313808, "target": 405884, "signals": [{"label": "손절선", "ok": true, "detail": "313,808 (현재 356,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 405,884"}, {"label": "추세(20일선)", "ok": true, "detail": "296,925"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +6 · 기 +26"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 355,034"}]}
|
||||
{"ts": "2026-07-23T12:54:04.491410+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 90900, "qty": 53, "cost": 4818423, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 80223, "target": 104155, "signals": [{"label": "손절선", "ok": true, "detail": "80,223 (현재 91,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 104,155"}, {"label": "추세(20일선)", "ok": true, "detail": "75,210"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -169 · 기 +311"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 90,945"}]}
|
||||
{"ts": "2026-07-24T09:00:05.632693+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 111800, "qty": 28, "proceeds": 3124296, "entry_price": 132189, "pnl": -577559, "pnl_pct": -15.42, "hold_from": "2026-07-10T12:14:02.666463+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "102,302 (현재 113,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 191,963"}, {"label": "추세(20일선)", "ok": false, "detail": "119,505"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -480 · 기 -206"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-24T09:00:05.633650+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18730, "qty": 290, "proceeds": 5421108, "entry_price": 18850, "pnl": -46212, "pnl_pct": -0.64, "hold_from": "2026-07-23T09:44:04.277793+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,760 (현재 18,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,030"}, {"label": "추세(20일선)", "ok": false, "detail": "18,841"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -5 · 기 -5"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,219"}]}
|
||||
{"ts": "2026-07-24T09:28:04.944506+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14510, "qty": 191, "cost": 2771826, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11815, "target": 18460, "signals": [{"label": "손절선", "ok": true, "detail": "11,815 (현재 15,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,460"}, {"label": "추세(20일선)", "ok": true, "detail": "12,845"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -857 · 기 +1,172"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,791"}]}
|
||||
{"ts": "2026-07-27T09:02:06.223077+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19100, "qty": 289, "cost": 5520728, "path": "pullback", "reason": "눌림목 지정가 20,032 도달", "stop": 18023, "target": 21254, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "19,070 vs 18,858"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,600 / 18,858"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,600 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+16.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +101 · 기 -3 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.13% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "146,760 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.13% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,032 (현재 19,070)"}]}
|
||||
{"ts": "2026-07-27T09:02:06.223286+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7470, "qty": 533, "cost": 3982107, "path": "pullback", "reason": "눌림목 지정가 7,910 도달", "stop": 6645, "target": 9119, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "7,470 vs 4,876"}, {"label": "정배열(5>20)", "ok": true, "detail": "6,452 / 4,876"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,452 / 4,889"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+82.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +49 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.79% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "49,327 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "0.79% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "74"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,910 (현재 7,470)"}]}
|
||||
{"ts": "2026-07-27T09:08:02.082537+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7910, "qty": 503, "cost": 3979327, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 6645, "target": 9119, "signals": [{"label": "손절선", "ok": true, "detail": "6,645 (현재 7,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,119"}, {"label": "추세(20일선)", "ok": true, "detail": "4,897"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +49 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,747"}]}
|
||||
{"ts": "2026-07-27T13:40:01.090664+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 6780, "qty": 1036, "proceeds": 7010383, "entry_price": 7684, "pnl": -951051, "pnl_pct": -11.76, "hold_from": "2026-07-27T09:02:06.223284+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "6,854 (현재 6,770)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,344"}, {"label": "추세(20일선)", "ok": true, "detail": "4,841"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +50 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-27T14:20:01.056082+09:00", "side": "BUY", "code": "218150", "name": "미래생명자원", "price": 2130, "qty": 1218, "cost": 2594729, "path": "pullback", "reason": "눌림목 지정가 2,135 도달", "stop": 1775, "target": 2840, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "2,130 vs 2,030"}, {"label": "정배열(5>20)", "ok": true, "detail": "2,415 / 2,030"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "2,415 / 2,265"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+41.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -118 · 기 +4 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "6.22% (환산) vs 평균 15.95% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 3,180"}, {"label": "거래량 급증", "ok": false, "detail": "1,270,804 (환산) vs 평균 3,257,264"}, {"label": "회전율 급증", "ok": false, "detail": "6.22% (환산) vs 평균 15.95%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 2,135 (현재 2,130)"}]}
|
||||
{"ts": "2026-07-28T09:00:15.827850+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 126200, "qty": 15, "proceeds": 1889309, "entry_price": 158100, "pnl": -482547, "pnl_pct": -20.18, "hold_from": "2026-07-22T15:04:00.990875+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "129,761 (현재 128,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 214,778"}, {"label": "추세(20일선)", "ok": false, "detail": "134,815"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +269 · 기 -554"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 168,554"}]}
|
||||
{"ts": "2026-07-28T09:00:15.829236+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6780, "qty": 495, "cost": 3356603, "path": "pullback", "reason": "눌림목 지정가 7,884 도달", "stop": 5916, "target": 8509, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "7,050 vs 4,855"}, {"label": "정배열(5>20)", "ok": true, "detail": "6,368 / 4,855"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,368 / 4,882"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+73.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +86 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.01% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "813 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "0.01% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "69"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,884 (현재 7,050)"}]}
|
||||
{"ts": "2026-07-28T09:02:04.745721+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 11920, "qty": 388, "proceeds": 4615941, "entry_price": 14266, "pnl": -920209, "pnl_pct": -16.45, "hold_from": "2026-07-22T14:18:05.419863+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "11,984 (현재 11,960)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,831"}, {"label": "추세(20일선)", "ok": false, "detail": "12,673"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +84 · 기 -61"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-28T09:10:01.738088+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7100, "qty": 473, "cost": 3358804, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5916, "target": 8509, "signals": [{"label": "손절선", "ok": true, "detail": "5,916 (현재 7,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,509"}, {"label": "추세(20일선)", "ok": true, "detail": "4,857"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +86 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,079"}]}
|
||||
{"ts": "2026-07-29T09:02:02.221428+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 117900, "qty": 44, "cost": 5188378, "path": "pullback", "reason": "눌림목 지정가 118,115 도달", "stop": 111200, "target": 131300, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "117,800 vs 113,445"}, {"label": "정배열(5>20)", "ok": true, "detail": "117,020 / 113,445"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "117,020 / 109,982"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+45.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +39 · 기 +307 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+26%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 123,300"}, {"label": "거래량 급증", "ok": false, "detail": "44,660 (환산) vs 평균 289,522"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.17%"}, {"label": "RSI", "ok": true, "detail": "57"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 118,115 (현재 117,800)"}]}
|
||||
{"ts": "2026-07-29T09:16:04.460893+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 120500, "qty": 43, "cost": 5182277, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111200, "target": 131300, "signals": [{"label": "손절선", "ok": true, "detail": "111,200 (현재 120,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 131,300"}, {"label": "추세(20일선)", "ok": true, "detail": "113,580"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +39 · 기 +307"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 120,492"}]}
|
||||
{"ts": "2026-07-29T09:24:08.691208+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18950, "qty": 292, "cost": 5534230, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 18023, "target": 21254, "signals": [{"label": "손절선", "ok": true, "detail": "18,023 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,254"}, {"label": "추세(20일선)", "ok": true, "detail": "18,884"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +99 · 기 +138"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,418"}]}
|
||||
{"ts": "2026-07-29T09:24:08.691404+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 128600, "qty": 77, "proceeds": 9882891, "entry_price": 134418, "pnl": -468862, "pnl_pct": -4.33, "hold_from": "2026-07-09T09:36:03.452341+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "122,826 (현재 116,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 157,603"}, {"label": "추세(20일선)", "ok": true, "detail": "107,870"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +245"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:24:09.102125+09:00", "side": "BUY", "code": "218150", "name": "미래생명자원", "price": 1951, "qty": 1330, "cost": 2595219, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 1775, "target": 2840, "signals": [{"label": "손절선", "ok": true, "detail": "1,775 (현재 2,450)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 2,840"}, {"label": "추세(20일선)", "ok": true, "detail": "2,005"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +40 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 2,287"}]}
|
||||
{"ts": "2026-07-29T09:28:01.378937+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6390, "qty": 819, "cost": 5234195, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5998, "target": 7174, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "6,380 vs 5,358"}, {"label": "정배열(5>20)", "ok": true, "detail": "5,742 / 5,358"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,742 / 5,622"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+43.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +73 · 기 -3 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.62% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "134,413 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.62% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "73"}]}
|
||||
{"ts": "2026-07-29T10:42:05.407176+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5980, "qty": 819, "proceeds": 4888070, "entry_price": 6390, "pnl": -346125, "pnl_pct": -6.42, "hold_from": "2026-07-29T09:28:01.378936+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,998 (현재 5,980)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,174"}, {"label": "추세(20일선)", "ok": true, "detail": "5,338"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +77 · 기 -3"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,521"}]}
|
||||
{"ts": "2026-07-29T11:18:04.419939+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 6000, "qty": 968, "proceeds": 5796674, "entry_price": 6936, "pnl": -918733, "pnl_pct": -13.5, "hold_from": "2026-07-28T09:00:15.829228+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "6,040 (현재 6,020)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,729"}, {"label": "추세(20일선)", "ok": true, "detail": "4,803"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +185 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-30T09:04:06.866646+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6130, "qty": 411, "cost": 2519808, "path": "pullback", "reason": "눌림목 지정가 7,792 도달", "stop": 5127, "target": 8135, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "6,120 vs 4,808"}, {"label": "정배열(5>20)", "ok": true, "detail": "6,182 / 4,808"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,182 / 4,866"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+65.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +234 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.31% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "267,687 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "4.31% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,792 (현재 6,120)"}]}
|
||||
{"ts": "2026-07-30T09:24:06.917510+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 131800, "qty": 37, "cost": 4877331, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 120690, "target": 154020, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "132,000 vs 108,925"}, {"label": "정배열(5>20)", "ok": true, "detail": "115,580 / 108,925"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,580 / 113,230"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+51.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -6 · 기 +178 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+20%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.66% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "738,813 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.66% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-30T11:58:07.822065+09:00", "side": "SELL", "code": "161890", "name": "한국콜마", "price": 91700, "qty": 110, "proceeds": 10067330, "entry_price": 101115, "pnl": -1056938, "pnl_pct": -9.31, "hold_from": "2026-07-22T09:04:01.813457+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "92,100 (현재 91,800)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 119,144"}, {"label": "추세(20일선)", "ok": false, "detail": "98,155"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -7 · 기 +40"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-31T09:04:07.266662+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 69100, "qty": 107, "proceeds": 7379282, "entry_price": 89537, "pnl": -2202655, "pnl_pct": -22.83, "hold_from": "2026-07-23T09:50:01.509012+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "81,303 (현재 79,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 106,006"}, {"label": "추세(20일선)", "ok": true, "detail": "74,655"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -179 · 기 +325"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-31T09:06:06.413012+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6550, "qty": 384, "cost": 2515577, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5127, "target": 8135, "signals": [{"label": "손절선", "ok": true, "detail": "5,127 (현재 6,640)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,135"}, {"label": "추세(20일선)", "ok": true, "detail": "4,834"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +355 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,453"}]}
|
||||
{"ts": "2026-08-03T09:34:07.943956+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 120300, "qty": 37, "proceeds": 4442420, "entry_price": 131800, "pnl": -434911, "pnl_pct": -8.73, "hold_from": "2026-07-30T09:24:06.917508+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "120,690 (현재 120,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,020"}, {"label": "추세(20일선)", "ok": true, "detail": "108,345"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -35 · 기 +48"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,311"}]}
|
||||
{"ts": "2026-08-03T09:44:09.371424+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6120, "qty": 804, "cost": 4921218, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5752, "target": 6857, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "6,120 vs 5,345"}, {"label": "정배열(5>20)", "ok": true, "detail": "5,690 / 5,345"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,690 / 5,618"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+31.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +80 · 기 +5 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.61% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "132,260 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.61% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-08-03T12:00:11.776427+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 12910, "qty": 333, "cost": 4299675, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11713, "target": 15304, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "12,930 vs 11,922"}, {"label": "정배열(5>20)", "ok": true, "detail": "12,138 / 11,922"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "12,138 / 11,924"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +7 · 기 +10 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+74%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.72% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 12,870"}, {"label": "거래량 급증", "ok": true, "detail": "208,994 (환산) vs 평균 99,303"}, {"label": "회전율 급증", "ok": true, "detail": "0.72% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-03T12:02:09.723448+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 13420, "qty": 320, "cost": 4295044, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11713, "target": 15304, "signals": [{"label": "손절선", "ok": true, "detail": "11,713 (현재 13,450)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 15,304"}, {"label": "추세(20일선)", "ok": true, "detail": "11,948"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +7 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,339"}]}
|
||||
{"ts": "2026-08-04T09:04:08.850906+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 135900, "qty": 12, "cost": 1631045, "path": "pullback", "reason": "눌림목 지정가 156,279 도달", "stop": 104818, "target": 198064, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "135,700 vs 135,200"}, {"label": "정배열(5>20)", "ok": true, "detail": "167,780 / 135,200"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "167,780 / 99,378"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+24.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +23 · 기 +54 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+45%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "6.62% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": true, "detail": "1,281,140 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": true, "detail": "6.62% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 156,279 (현재 135,700)"}]}
|
||||
{"ts": "2026-08-05T09:00:13.753117+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 147600, "qty": 11, "cost": 1623844, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 104818, "target": 198064, "signals": [{"label": "손절선", "ok": true, "detail": "104,818 (현재 147,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 198,064"}, {"label": "추세(20일선)", "ok": true, "detail": "135,790"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -256 · 기 +200"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 145,836"}]}
|
||||
{"ts": "2026-08-05T09:02:12.586918+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5700, "qty": 804, "proceeds": 4573864, "entry_price": 6120, "pnl": -347355, "pnl_pct": -6.86, "hold_from": "2026-08-03T09:44:09.371421+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,752 (현재 5,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 6,857"}, {"label": "추세(20일선)", "ok": true, "detail": "5,326"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +14 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,233"}]}
|
||||
{"ts": "2026-08-05T09:18:09.783204+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13560, "qty": 171, "cost": 2319108, "path": "pullback", "reason": "눌림목 지정가 13,947 도달", "stop": 11230, "target": 18219, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "13,540 vs 12,752"}, {"label": "정배열(5>20)", "ok": true, "detail": "14,456 / 12,752"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,456 / 13,275"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-2.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -72 · 기 +134 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.18% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "708,253 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "1.18% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,947 (현재 13,540)"}]}
|
||||
{"ts": "2026-08-05T10:00:11.361876+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14270, "qty": 162, "cost": 2312087, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11230, "target": 18219, "signals": [{"label": "손절선", "ok": true, "detail": "11,230 (현재 14,340)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,219"}, {"label": "추세(20일선)", "ok": true, "detail": "12,792"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -72 · 기 +134"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,337"}]}
|
||||
{"ts": "2026-08-06T09:00:17.919075+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18730, "qty": 581, "proceeds": 10860910, "entry_price": 19025, "pnl": -194048, "pnl_pct": -1.55, "hold_from": "2026-07-27T09:02:06.223075+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "18,070 (현재 18,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,933"}, {"label": "추세(20일선)", "ok": false, "detail": "18,842"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -203 · 기 -147"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-06T09:34:15.275497+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 192000, "qty": 25, "cost": 4800720, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 179508, "target": 216984, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "191,800 vs 177,680"}, {"label": "정배열(5>20)", "ok": true, "detail": "186,140 / 177,680"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "186,140 / 176,671"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +255 · 기 -319 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+44%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.67% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 191,700"}, {"label": "거래량 급증", "ok": true, "detail": "1,582,767 (환산) vs 평균 738,783"}, {"label": "회전율 급증", "ok": true, "detail": "0.67% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-07T09:12:15.076918+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 196400, "qty": 25, "cost": 4910736, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 179508, "target": 216984, "signals": [{"label": "손절선", "ok": true, "detail": "179,508 (현재 196,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 216,984"}, {"label": "추세(20일선)", "ok": true, "detail": "177,905"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +338 · 기 +73"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 196,264"}]}
|
||||
{"ts": "2026-08-12T09:30:11.936727+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 400000, "qty": 27, "proceeds": 10778940, "entry_price": 350037, "pnl": 1326522, "pnl_pct": 14.27, "hold_from": "2026-07-21T12:06:01.164331+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "399,647 (현재 399,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "299,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -6 · 기 +72"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-12T09:38:12.223439+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19050, "qty": 268, "cost": 5106166, "path": "pullback", "reason": "눌림목 지정가 20,036 도달", "stop": 17979, "target": 21193, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "19,040 vs 18,856"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,594 / 18,856"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,594 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +113 · 기 +20 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+32%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.22% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "250,373 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.22% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,036 (현재 19,040)"}]}
|
||||
{"ts": "2026-08-13T09:04:16.533363+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18820, "qty": 268, "proceeds": 5033925, "entry_price": 19050, "pnl": -72241, "pnl_pct": -1.21, "hold_from": "2026-08-12T09:38:12.223437+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,979 (현재 18,830)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,193"}, {"label": "추세(20일선)", "ok": false, "detail": "18,846"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -50 · 기 -76"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,414"}]}
|
||||
{"ts": "2026-08-13T09:06:16.481896+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 403000, "qty": 10, "cost": 4030605, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 365665, "target": 477670, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "404,000 vs 299,325"}, {"label": "정배열(5>20)", "ok": true, "detail": "327,000 / 299,325"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "327,000 / 306,483"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+33.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +27 · 기 +23 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+25%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.93% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "200,187 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.93% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "75"}]}
|
||||
{"ts": "2026-08-13T14:06:12.772245+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 416500, "qty": 10, "cost": 4165625, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 365665, "target": 477670, "signals": [{"label": "손절선", "ok": true, "detail": "365,665 (현재 416,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 477,670"}, {"label": "추세(20일선)", "ok": true, "detail": "299,925"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +19"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 415,570"}]}
|
||||
{"ts": "2026-08-18T09:02:22.304396+09:00", "side": "SELL", "code": "138040", "name": "메리츠금융지주", "price": 115600, "qty": 87, "proceeds": 10037588, "entry_price": 119185, "pnl": -333067, "pnl_pct": -3.01, "hold_from": "2026-07-29T09:02:02.221426+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "111,408 (현재 116,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 134,739"}, {"label": "추세(20일선)", "ok": true, "detail": "113,375"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +129 · 기 -137"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-19T09:02:19.956688+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 135200, "qty": 23, "proceeds": 3103536, "entry_price": 141496, "pnl": -151352, "pnl_pct": -4.45, "hold_from": "2026-08-04T09:04:08.850905+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "111,689 (현재 135,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 201,110"}, {"label": "추세(20일선)", "ok": false, "detail": "135,165"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -41 · 기 -106"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-19T09:06:21.232233+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 5530, "qty": 795, "proceeds": 4387777, "entry_price": 6333, "pnl": -647608, "pnl_pct": -12.68, "hold_from": "2026-07-30T09:04:06.866644+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "5,362 (현재 5,560)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,274"}, {"label": "추세(20일선)", "ok": true, "detail": "4,780"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +101 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-19T09:06:21.871145+09:00", "side": "SELL", "code": "218150", "name": "미래생명자원", "price": 2030, "qty": 2548, "proceeds": 5162354, "entry_price": 2037, "pnl": -27595, "pnl_pct": -0.32, "hold_from": "2026-07-27T14:20:01.056080+09:00", "reason": "시간손절 — 22일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "1,775 (현재 2,025)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 2,720"}, {"label": "추세(20일선)", "ok": false, "detail": "2,079"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +20 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-20T09:02:17.450992+09:00", "side": "BUY", "code": "327260", "name": "RF머트리얼즈", "price": 33400, "qty": 50, "cost": 1670251, "path": "pullback", "reason": "눌림목 지정가 35,709 도달", "stop": 25688, "target": 48823, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "33,000 vs 26,058"}, {"label": "정배열(5>20)", "ok": true, "detail": "35,100 / 26,058"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "35,100 / 31,286"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -71 · 기 +7 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+106%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.67% (환산) vs 평균 3.74% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 45,300"}, {"label": "거래량 급증", "ok": false, "detail": "60,673 (환산) vs 평균 409,191"}, {"label": "회전율 급증", "ok": false, "detail": "0.67% (환산) vs 평균 3.74%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 35,709 (현재 33,000)"}]}
|
||||
{"ts": "2026-08-20T09:02:17.451493+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 144900, "qty": 13, "cost": 1883983, "path": "pullback", "reason": "눌림목 지정가 157,486 도달", "stop": 115629, "target": 203443, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "145,500 vs 135,690"}, {"label": "정배열(5>20)", "ok": true, "detail": "169,740 / 135,690"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "169,740 / 99,542"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+11.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +107 · 기 -119 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+48%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.38% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "266,680 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "1.38% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 157,486 (현재 145,500)"}]}
|
||||
{"ts": "2026-08-20T09:02:17.451858+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 5500, "qty": 379, "cost": 2084813, "path": "pullback", "reason": "눌림목 지정가 7,771 도달", "stop": 4466, "target": 7567, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "5,480 vs 4,776"}, {"label": "정배열(5>20)", "ok": true, "detail": "6,054 / 4,776"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,054 / 4,856"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +53 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.51% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "93,867 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "1.51% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "54"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,771 (현재 5,480)"}]}
|
||||
{"ts": "2026-08-20T09:02:17.452078+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 115000, "qty": 42, "cost": 4830724, "path": "pullback", "reason": "눌림목 지정가 117,922 도달", "stop": 111200, "target": 122600, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "114,800 vs 113,295"}, {"label": "정배열(5>20)", "ok": true, "detail": "116,420 / 113,295"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "116,420 / 109,932"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+16.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +156 · 기 -217 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.02% (환산) vs 평균 0.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 123,300"}, {"label": "거래량 급증", "ok": false, "detail": "34,100 (환산) vs 평균 289,522"}, {"label": "회전율 급증", "ok": false, "detail": "0.02% (환산) vs 평균 0.17%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 117,922 (현재 114,800)"}]}
|
||||
{"ts": "2026-08-20T09:16:21.642669+09:00", "side": "BUY", "code": "327260", "name": "RF머트리얼즈", "price": 35800, "qty": 47, "cost": 1682852, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 25688, "target": 48823, "signals": [{"label": "손절선", "ok": true, "detail": "25,688 (현재 36,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 48,823"}, {"label": "추세(20일선)", "ok": true, "detail": "26,208"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -71 · 기 +7"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 35,971"}]}
|
||||
{"ts": "2026-08-24T09:00:21.444249+09:00", "side": "SELL", "code": "194370", "name": "제이에스코퍼레이션", "price": 12790, "qty": 653, "proceeds": 8335584, "entry_price": 13160, "pnl": -259135, "pnl_pct": -2.81, "hold_from": "2026-08-03T12:00:11.776417+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "11,874 (현재 12,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 15,732"}, {"label": "추세(20일선)", "ok": true, "detail": "11,913"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -59 · 기 -20"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-24T09:02:22.276759+09:00", "side": "BUY", "code": "316140", "name": "우리금융지주", "price": 31550, "qty": 153, "cost": 4827874, "path": "pullback", "reason": "눌림목 지정가 31,946 도달", "stop": 29900, "target": 34850, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "31,500 vs 31,388"}, {"label": "정배열(5>20)", "ok": true, "detail": "32,320 / 31,388"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "32,320 / 30,870"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-0.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -988 · 기 +144 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+39%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.08% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 33,850"}, {"label": "거래량 급증", "ok": false, "detail": "551,940 (환산) vs 평균 2,489,243"}, {"label": "회전율 급증", "ok": false, "detail": "0.08% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 31,946 (현재 31,500)"}]}
|
||||
{"ts": "2026-08-25T09:20:20.388186+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 12360, "qty": 333, "proceeds": 4107854, "entry_price": 13905, "pnl": -523341, "pnl_pct": -11.11, "hold_from": "2026-08-05T09:18:09.783202+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "11,576 (현재 12,370)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,564"}, {"label": "추세(20일선)", "ok": false, "detail": "12,694"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +34 · 기 -30"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-25T09:34:17.879110+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 118200, "qty": 41, "cost": 4846927, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111200, "target": 122600, "signals": [{"label": "손절선", "ok": true, "detail": "111,200 (현재 118,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 122,600"}, {"label": "추세(20일선)", "ok": true, "detail": "113,465"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -39 · 기 +19"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 117,689"}]}
|
||||
{"ts": "2026-08-25T09:46:20.069247+09:00", "side": "BUY", "code": "316140", "name": "우리금융지주", "price": 32400, "qty": 149, "cost": 4828324, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 29900, "target": 34850, "signals": [{"label": "손절선", "ok": true, "detail": "29,900 (현재 32,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 34,850"}, {"label": "추세(20일선)", "ok": true, "detail": "31,432"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,322 · 기 +436"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 32,372"}]}
|
||||
{"ts": "2026-08-25T12:16:22.494708+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19190, "qty": 253, "cost": 4855798, "path": "pullback", "reason": "눌림목 지정가 20,031 도달", "stop": 18112, "target": 21346, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "19,180 vs 18,864"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,622 / 18,864"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,622 / 19,362"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+0.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +274 · 기 -97 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+31%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.21% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "241,100 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.21% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,031 (현재 19,180)"}]}
|
||||
{"ts": "2026-08-26T09:36:23.410378+09:00", "side": "SELL", "code": "068270", "name": "셀트리온", "price": 193100, "qty": 50, "proceeds": 9636173, "entry_price": 194200, "pnl": -75284, "pnl_pct": -0.57, "hold_from": "2026-08-06T09:34:15.275495+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "181,408 (현재 193,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 219,784"}, {"label": "추세(20일선)", "ok": true, "detail": "177,750"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -64 · 기 -2"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-26T09:36:23.410657+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 133100, "qty": 33, "cost": 4392959, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 121465, "target": 156370, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "133,200 vs 108,985"}, {"label": "정배열(5>20)", "ok": true, "detail": "115,820 / 108,985"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,820 / 113,250"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+7.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +5 · 기 -29 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+27%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.70% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "789,493 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.70% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-08-27T09:00:37.362598+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 143200, "qty": 31, "cost": 4439866, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 121465, "target": 156370, "signals": [{"label": "손절선", "ok": true, "detail": "121,465 (현재 141,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 156,370"}, {"label": "추세(20일선)", "ok": true, "detail": "109,400"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +46 · 기 -18"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 137,114"}]}
|
||||
{"ts": "2026-09-02T09:08:22.019259+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 401500, "qty": 20, "proceeds": 8014342, "entry_price": 409750, "pnl": -181888, "pnl_pct": -2.01, "hold_from": "2026-08-13T09:06:16.481894+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "372,040 (현재 403,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 485,170"}, {"label": "추세(20일선)", "ok": true, "detail": "299,312"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +27 · 기 -26"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-03T09:02:24.986236+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13520, "qty": 174, "cost": 2352833, "path": "pullback", "reason": "눌림목 지정가 13,982 도달", "stop": 11243, "target": 18074, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "13,430 vs 12,746"}, {"label": "정배열(5>20)", "ok": true, "detail": "14,434 / 12,746"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,434 / 13,273"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-9.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -6 · 기 +7 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+86%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.08% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "47,427 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.08% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,982 (현재 13,430)"}]}
|
||||
{"ts": "2026-09-07T09:02:26.079168+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 157600, "qty": 12, "cost": 1891484, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 115629, "target": 203443, "signals": [{"label": "손절선", "ok": true, "detail": "115,629 (현재 157,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 203,443"}, {"label": "추세(20일선)", "ok": true, "detail": "136,265"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +212 · 기 -125"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 154,446"}]}
|
||||
{"ts": "2026-09-08T15:20:32.432185+09:00", "side": "SELL", "code": "138040", "name": "메리츠금융지주", "price": 130000, "qty": 83, "proceeds": 10768960, "entry_price": 116581, "pnl": 1091308, "pnl_pct": 11.51, "hold_from": "2026-08-20T09:02:17.452076+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "130,576 (현재 130,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "114,070"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -147 · 기 +423"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-09T09:02:32.233013+09:00", "side": "BUY", "code": "012450", "name": "한화에어로스페이스", "price": 1028000, "qty": 4, "cost": 4112617, "path": "pullback", "reason": "눌림목 지정가 1,094,989 도달", "stop": 998000, "target": 1088000, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "1,025,000 vs 1,024,150"}, {"label": "정배열(5>20)", "ok": true, "detail": "1,138,600 / 1,024,150"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "1,138,600 / 1,049,650"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+5.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -67 · 기 +27 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+62%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.11% (환산) vs 평균 0.44% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 1,255,000"}, {"label": "거래량 급증", "ok": false, "detail": "58,353 (환산) vs 평균 227,218"}, {"label": "회전율 급증", "ok": false, "detail": "0.11% (환산) vs 평균 0.44%"}, {"label": "RSI", "ok": true, "detail": "48"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 1,094,989 (현재 1,025,000)"}]}
|
||||
{"ts": "2026-09-09T09:04:29.142548+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 4745, "qty": 379, "proceeds": 1794848, "entry_price": 5500, "pnl": -289964, "pnl_pct": -13.73, "hold_from": "2026-08-20T09:02:17.451855+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "4,466 (현재 4,765)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,567"}, {"label": "추세(20일선)", "ok": true, "detail": "4,740"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -13 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 5,871"}]}
|
||||
{"ts": "2026-09-09T09:04:29.143163+09:00", "side": "BUY", "code": "004370", "name": "농심", "price": 418500, "qty": 12, "cost": 5022753, "path": "pullback", "reason": "눌림목 지정가 420,748 도달", "stop": 393372, "target": 468756, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "418,000 vs 384,125"}, {"label": "정배열(5>20)", "ok": true, "detail": "406,100 / 384,125"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "406,100 / 370,025"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+9.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +11 · 기 -28 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+35%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.18% (환산) vs 평균 0.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 440,000"}, {"label": "거래량 급증", "ok": false, "detail": "10,720 (환산) vs 평균 31,191"}, {"label": "회전율 급증", "ok": false, "detail": "0.18% (환산) vs 평균 0.51%"}, {"label": "RSI", "ok": true, "detail": "64"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 420,748 (현재 418,000)"}]}
|
||||
{"ts": "2026-09-09T09:10:28.368498+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 150800, "qty": 25, "proceeds": 3762648, "entry_price": 150996, "pnl": -12818, "pnl_pct": -0.13, "hold_from": "2026-08-20T09:02:17.451490+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "122,357 (현재 150,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 208,274"}, {"label": "추세(20일선)", "ok": true, "detail": "135,935"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +129 · 기 +107"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-09T09:10:28.369785+09:00", "side": "BUY", "code": "060250", "name": "NHN KCP", "price": 14750, "qty": 264, "cost": 3894584, "path": "pullback", "reason": "눌림목 지정가 15,087 도달", "stop": 13230, "target": 17790, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "14,690 vs 14,358"}, {"label": "정배열(5>20)", "ok": true, "detail": "15,356 / 14,358"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,356 / 13,592"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+24.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +52 · 기 +69 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.42% (환산) vs 평균 1.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 16,190"}, {"label": "거래량 급증", "ok": false, "detail": "166,893 (환산) vs 평균 608,910"}, {"label": "회전율 급증", "ok": false, "detail": "0.42% (환산) vs 평균 1.51%"}, {"label": "RSI", "ok": true, "detail": "53"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 15,087 (현재 14,690)"}]}
|
||||
{"ts": "2026-09-10T09:00:55.811688+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18810, "qty": 253, "proceeds": 4749650, "entry_price": 19190, "pnl": -106148, "pnl_pct": -1.98, "hold_from": "2026-08-25T12:16:22.494706+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "18,112 (현재 18,810)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,346"}, {"label": "추세(20일선)", "ok": false, "detail": "18,845"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -262 · 기 -49"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,555"}]}
|
||||
{"ts": "2026-09-10T09:00:55.814439+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 11890, "qty": 367, "cost": 4364285, "path": "pullback", "reason": "눌림목 지정가 12,003 도달", "stop": 10784, "target": 14102, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "11,900 vs 10,158"}, {"label": "정배열(5>20)", "ok": true, "detail": "11,810 / 10,158"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "11,810 / 9,431"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+36.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +5 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 0.30% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 13,450"}, {"label": "거래량 급증", "ok": false, "detail": "0 (환산) vs 평균 36,789"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 0.30%"}, {"label": "RSI", "ok": true, "detail": "66"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 12,003 (현재 11,900)"}]}
|
||||
{"ts": "2026-09-10T10:04:32.005765+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 152500, "qty": 64, "proceeds": 9740968, "entry_price": 137992, "pnl": 908143, "pnl_pct": 10.51, "hold_from": "2026-08-26T09:36:23.410654+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "153,508 (현재 152,800)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "109,965"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +0 · 기 +306"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-10T13:40:32.100659+09:00", "side": "BUY", "code": "012450", "name": "한화에어로스페이스", "price": 1070000, "qty": 4, "cost": 4280642, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 998000, "target": 1088000, "signals": [{"label": "손절선", "ok": true, "detail": "998,000 (현재 1,069,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 1,088,000"}, {"label": "추세(20일선)", "ok": true, "detail": "1,026,350"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -44 · 기 +13"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 1,068,505"}]}
|
||||
{"ts": "2026-09-10T14:02:37.660602+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 117800, "qty": 33, "cost": 3887983, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 105837, "target": 141726, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "117,700 vs 76,545"}, {"label": "정배열(5>20)", "ok": true, "detail": "88,900 / 76,545"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "88,900 / 75,550"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+67.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +196 · 기 -113 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+24%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.95% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "884,443 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.95% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "82"}]}
|
||||
{"ts": "2026-09-11T09:04:34.983232+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14660, "qty": 161, "cost": 2360614, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11243, "target": 18074, "signals": [{"label": "손절선", "ok": true, "detail": "11,243 (현재 14,510)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,074"}, {"label": "추세(20일선)", "ok": true, "detail": "12,800"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -103 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,275"}]}
|
||||
{"ts": "2026-09-15T10:28:40.298899+09:00", "side": "SELL", "code": "012450", "name": "한화에어로스페이스", "price": 1063000, "qty": 8, "proceeds": 8487417, "entry_price": 1049000, "pnl": 94158, "pnl_pct": 1.33, "hold_from": "2026-09-09T09:02:32.233010+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "1,064,063 (현재 1,064,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": false, "detail": "1,089,400"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +88 · 기 -20"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-15T10:28:40.300996+09:00", "side": "BUY", "code": "261520", "name": "이지스", "price": 5100, "qty": 681, "cost": 3473621, "path": "pullback", "reason": "눌림목 지정가 5,108 도달", "stop": 4497, "target": 6305, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "5,100 vs 4,478"}, {"label": "정배열(5>20)", "ok": true, "detail": "5,199 / 4,478"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,199 / 4,257"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -27 · 기 +72 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.68% (환산) vs 평균 1.40% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 5,510"}, {"label": "거래량 급증", "ok": false, "detail": "70,632 (환산) vs 평균 143,937"}, {"label": "회전율 급증", "ok": false, "detail": "0.68% (환산) vs 평균 1.40%"}, {"label": "RSI", "ok": true, "detail": "62"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 5,108 (현재 5,100)"}]}
|
||||
{"ts": "2026-09-16T09:02:38.444209+09:00", "side": "SELL", "code": "060250", "name": "NHN KCP", "price": 12830, "qty": 264, "proceeds": 3380515, "entry_price": 14750, "pnl": -514069, "pnl_pct": -13.02, "hold_from": "2026-09-09T09:10:28.369781+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "13,230 (현재 12,850)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,790"}, {"label": "추세(20일선)", "ok": false, "detail": "14,266"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +14 · 기 +18"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 15,348"}]}
|
||||
{"ts": "2026-09-16T13:08:39.885464+09:00", "side": "BUY", "code": "372170", "name": "윤성에프앤씨", "price": 20200, "qty": 138, "cost": 2788018, "path": "pullback", "reason": "눌림목 지정가 21,341 도달", "stop": 17261, "target": 26078, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "20,150 vs 16,762"}, {"label": "정배열(5>20)", "ok": true, "detail": "19,666 / 16,762"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,666 / 19,448"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+39.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +0 · 기 +1 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.18% (환산) vs 평균 0.56% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 23,400"}, {"label": "거래량 급증", "ok": false, "detail": "14,282 (환산) vs 평균 44,688"}, {"label": "회전율 급증", "ok": false, "detail": "0.18% (환산) vs 평균 0.56%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 21,341 (현재 20,150)"}]}
|
||||
{"ts": "2026-09-17T09:04:38.313675+09:00", "side": "SELL", "code": "004370", "name": "농심", "price": 392500, "qty": 12, "proceeds": 4700816, "entry_price": 418500, "pnl": -321938, "pnl_pct": -6.21, "hold_from": "2026-09-09T09:04:29.143160+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "393,372 (현재 393,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 468,756"}, {"label": "추세(20일선)", "ok": true, "detail": "382,875"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +9 · 기 +1"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 427,680"}]}
|
||||
{"ts": "2026-09-17T09:04:38.748549+09:00", "side": "BUY", "code": "300720", "name": "한일시멘트", "price": 16170, "qty": 314, "cost": 5078142, "path": "pullback", "reason": "눌림목 지정가 16,386 도달", "stop": 15324, "target": 17862, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "16,080 vs 15,208"}, {"label": "정배열(5>20)", "ok": true, "detail": "16,298 / 15,208"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "16,298 / 14,435"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+16.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +7 · 기 +8 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+27%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.02% (환산) vs 평균 0.12% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 17,140"}, {"label": "거래량 급증", "ok": false, "detail": "11,173 (환산) vs 평균 89,609"}, {"label": "회전율 급증", "ok": false, "detail": "0.02% (환산) vs 평균 0.12%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 16,386 (현재 16,080)"}]}
|
||||
{"ts": "2026-09-18T10:36:42.187283+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 12290, "qty": 355, "cost": 4363604, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10784, "target": 14102, "signals": [{"label": "손절선", "ok": true, "detail": "10,784 (현재 12,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 14,102"}, {"label": "추세(20일선)", "ok": true, "detail": "10,178"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +17 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 12,253"}]}
|
||||
{"ts": "2026-09-29T11:06:44.794791+09:00", "side": "BUY", "code": "261520", "name": "이지스", "price": 5390, "qty": 644, "cost": 3471681, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 4497, "target": 6305, "signals": [{"label": "손절선", "ok": true, "detail": "4,497 (현재 5,350)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 6,305"}, {"label": "추세(20일선)", "ok": true, "detail": "4,490"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -18 · 기 +5"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 5,298"}]}
|
||||
{"ts": "2026-09-30T14:04:45.036517+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 106100, "qty": 33, "proceeds": 3494472, "entry_price": 117800, "pnl": -393511, "pnl_pct": -9.93, "hold_from": "2026-09-10T14:02:37.660601+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "105,837 (현재 106,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 141,726"}, {"label": "추세(20일선)", "ok": true, "detail": "75,975"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +293 · 기 -347"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 121,202"}]}
|
||||
{"ts": "2026-09-30T14:04:45.461296+09:00", "side": "BUY", "code": "257720", "name": "실리콘투", "price": 38400, "qty": 84, "cost": 3226084, "path": "pullback", "reason": "눌림목 지정가 39,510 도달", "stop": 33500, "target": 48200, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "38,350 vs 37,605"}, {"label": "정배열(5>20)", "ok": true, "detail": "41,460 / 37,605"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "41,460 / 36,080"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+8.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +56 · 기 -522 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+56%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.25% (환산) vs 평균 1.56% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 48,250"}, {"label": "거래량 급증", "ok": false, "detail": "822,398 (환산) vs 평균 1,023,767"}, {"label": "회전율 급증", "ok": false, "detail": "1.25% (환산) vs 평균 1.56%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 39,510 (현재 38,350)"}]}
|
||||
{"ts": "2026-10-01T09:00:54.036317+09:00", "side": "SELL", "code": "316140", "name": "우리금융지주", "price": 32800, "qty": 302, "proceeds": 9886284, "entry_price": 31969, "pnl": 230086, "pnl_pct": 2.6, "hold_from": "2026-08-24T09:02:22.276742+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "34,483 (현재 33,850)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "31,505"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -303 · 기 -158"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-01T09:00:54.040856+09:00", "side": "BUY", "code": "307870", "name": "비투엔", "price": 1198, "qty": 2721, "cost": 3260247, "path": "pullback", "reason": "눌림목 지정가 1,241 도달", "stop": 1047, "target": 1501, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "1,201 vs 1,140"}, {"label": "정배열(5>20)", "ok": true, "detail": "1,313 / 1,140"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "1,313 / 1,000"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+34.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -78 · 기 +4 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 1.90% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 1,390"}, {"label": "거래량 급증", "ok": false, "detail": "0 (환산) vs 평균 1,410,399"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 1.90%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 1,241 (현재 1,201)"}]}
|
||||
{"ts": "2026-10-01T09:58:42.889067+09:00", "side": "SELL", "code": "257720", "name": "실리콘투", "price": 37550, "qty": 84, "proceeds": 3148049, "entry_price": 38400, "pnl": -78035, "pnl_pct": -2.21, "hold_from": "2026-09-30T14:04:45.461284+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "33,500 (현재 37,550)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 48,200"}, {"label": "추세(20일선)", "ok": false, "detail": "37,565"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -231 · 기 -208"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 40,093"}]}
|
||||
{"ts": "2026-10-01T09:58:43.236189+09:00", "side": "BUY", "code": "048910", "name": "대원미디어", "price": 6990, "qty": 737, "cost": 5152403, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 6454, "target": 8062, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "7,000 vs 4,806"}, {"label": "정배열(5>20)", "ok": true, "detail": "5,552 / 4,806"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,552 / 4,914"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+49.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -49 · 기 +12 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.76% (환산) vs 평균 0.30% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,630"}, {"label": "거래량 급증", "ok": true, "detail": "95,240 (환산) vs 평균 38,349"}, {"label": "회전율 급증", "ok": true, "detail": "0.76% (환산) vs 평균 0.30%"}, {"label": "RSI", "ok": true, "detail": "86"}]}
|
||||
{"ts": "2026-10-01T10:16:47.426944+09:00", "side": "BUY", "code": "048910", "name": "대원미디어", "price": 7170, "qty": 719, "cost": 5156003, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 6454, "target": 8062, "signals": [{"label": "손절선", "ok": true, "detail": "6,454 (현재 7,180)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,062"}, {"label": "추세(20일선)", "ok": true, "detail": "4,814"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -49 · 기 +12"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,169"}]}
|
||||
{"ts": "2026-10-01T12:24:42.427319+09:00", "side": "BUY", "code": "307870", "name": "비투엔", "price": 1243, "qty": 2622, "cost": 3259635, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 1047, "target": 1501, "signals": [{"label": "손절선", "ok": true, "detail": "1,047 (현재 1,250)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 1,501"}, {"label": "추세(20일선)", "ok": true, "detail": "1,143"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -68 · 기 +4"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 1,249"}]}
|
||||
{"ts": "2026-10-02T09:01:23.801920+09:00", "side": "BUY", "code": "372170", "name": "윤성에프앤씨", "price": 21400, "qty": 130, "cost": 2782417, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17261, "target": 26078, "signals": [{"label": "손절선", "ok": true, "detail": "17,261 (현재 21,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 26,078"}, {"label": "추세(20일선)", "ok": true, "detail": "16,840"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +2 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 21,139"}]}
|
||||
{"ts": "2026-10-07T09:06:53.601882+09:00", "side": "SELL", "code": "300720", "name": "한일시멘트", "price": 15940, "qty": 314, "proceeds": 4995400, "entry_price": 16170, "pnl": -82742, "pnl_pct": -1.42, "hold_from": "2026-09-17T09:04:38.748535+09:00", "reason": "시간손절 — 20일 보유·진전 없음(자본 회전)", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "15,324 (현재 16,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,862"}, {"label": "추세(20일선)", "ok": true, "detail": "15,204"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -16 · 기 +15"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 16,456"}]}
|
||||
{"ts": "2026-10-07T09:06:54.037826+09:00", "side": "BUY", "code": "295310", "name": "에이치브이엠", "price": 75200, "qty": 58, "cost": 4362254, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 67951, "target": 89698, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "74,500 vs 51,322"}, {"label": "정배열(5>20)", "ok": true, "detail": "62,140 / 51,322"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "62,140 / 47,968"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+45.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -68 · 기 +241 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+12%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.24% (환산) vs 평균 0.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 61,900"}, {"label": "거래량 급증", "ok": true, "detail": "278,607 (환산) vs 평균 120,414"}, {"label": "회전율 급증", "ok": true, "detail": "2.24% (환산) vs 평균 0.97%"}, {"label": "RSI", "ok": true, "detail": "77"}]}
|
||||
{"ts": "2026-10-08T09:02:53.411594+09:00", "side": "SELL", "code": "327260", "name": "RF머트리얼즈", "price": 52400, "qty": 97, "proceeds": 5072889, "entry_price": 34563, "pnl": 1719786, "pnl_pct": 51.61, "hold_from": "2026-08-20T09:02:17.450989+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "53,034 (현재 52,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(20일선)", "ok": true, "detail": "44,220"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -85 · 기 +19"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-10-08T09:02:53.413418+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 301000, "qty": 16, "cost": 4816722, "path": "pullback", "reason": "눌림목 지정가 303,967 도달", "stop": 276000, "target": 351000, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "303,500 vs 294,300"}, {"label": "정배열(5>20)", "ok": true, "detail": "306,900 / 294,300"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "306,900 / 304,808"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-5.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +88 · 기 -120 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+73%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.37% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": false, "detail": "38,207 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": false, "detail": "0.37% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 303,967 (현재 303,500)"}]}
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"cash": 92805420.975,
|
||||
"initial": 100000000,
|
||||
"positions": {
|
||||
"035420": {
|
||||
"code": "035420",
|
||||
"name": "NAVER",
|
||||
"sources": [
|
||||
"interest"
|
||||
],
|
||||
"qty": 31,
|
||||
"entry_price": 232048.38709677418,
|
||||
"entry_at": "2026-06-11T10:25:02.231934+09:00",
|
||||
"stop": 197164,
|
||||
"target": 301817,
|
||||
"peak": 263000,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 232,280 도달",
|
||||
"cur_price": 248750,
|
||||
"tranches": 2,
|
||||
"tranche_value": 3741721.8543046354,
|
||||
"last_add_price": 238500
|
||||
}
|
||||
},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100958420.975,
|
||||
"max_drawdown": 0.0059876134567287895,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944280+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.283725+09:00"
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{"ts": "2026-06-11T10:25:02.231935+09:00", "side": "BUY", "code": "035420", "name": "NAVER", "price": 226000, "qty": 16, "cost": 3616542, "path": "pullback", "reason": "눌림목 지정가 232,280 도달", "stop": 195800, "target": 286400, "signals": [{"label": "추세(20일선 위)", "ok": true, "detail": "226,000 vs 224,445"}, {"label": "정배열(5>20)", "ok": true, "detail": "257,000 / 224,445"}, {"label": "추세 기울기(20일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "257,000 / 215,498"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -85 · 기 +1,187 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+45%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": false, "detail": "약세 — 돌파만 허용"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "6.51% (환산) vs 평균 2.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 304,000"}, {"label": "거래량 급증", "ok": true, "detail": "10,205,151 (환산) vs 평균 3,404,288"}, {"label": "회전율 급증", "ok": true, "detail": "6.51% (환산) vs 평균 2.17%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 232,280 (현재 226,000)"}]}
|
||||
{"ts": "2026-06-12T09:20:02.965943+09:00", "side": "BUY", "code": "035420", "name": "NAVER", "price": 238500, "qty": 15, "cost": 3578037, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 195800, "target": 286400, "signals": [{"label": "손절선", "ok": true, "detail": "195,800 (현재 238,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 286,400"}, {"label": "추세(20일선)", "ok": true, "detail": "225,070"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -159 · 기 +1,154"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 237,628"}]}
|
||||
@@ -1,13 +1,204 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"cash": 23737717.020249996,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944290+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.284988+09:00"
|
||||
"positions": {
|
||||
"086790": {
|
||||
"code": "086790",
|
||||
"name": "하나금융지주",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 83,
|
||||
"entry_price": 123226.50602409638,
|
||||
"entry_at": "2026-07-30T09:04:07.455785+09:00",
|
||||
"stop": 113273,
|
||||
"target": 143133,
|
||||
"peak": 141800,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 122,665 도달",
|
||||
"cur_price": 125200,
|
||||
"tranches": 2,
|
||||
"tranche_value": 5180051.180047499,
|
||||
"last_add_price": 125200
|
||||
},
|
||||
"194370": {
|
||||
"code": "194370",
|
||||
"name": "제이에스코퍼레이션",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 677,
|
||||
"entry_price": 13160.10339734121,
|
||||
"entry_at": "2026-08-03T12:00:11.789224+09:00",
|
||||
"stop": 11874,
|
||||
"target": 15732,
|
||||
"peak": 14150,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "breakout",
|
||||
"entry_reason": "거래량·회전율 동반 전고점 돌파",
|
||||
"cur_price": 12200,
|
||||
"tranches": 2,
|
||||
"tranche_value": 4464385.527789056,
|
||||
"last_add_price": 13420
|
||||
},
|
||||
"252990": {
|
||||
"code": "252990",
|
||||
"name": "샘씨엔에스",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 357,
|
||||
"entry_price": 14066.050420168067,
|
||||
"entry_at": "2026-09-03T09:02:24.991070+09:00",
|
||||
"stop": 18268,
|
||||
"target": 18596,
|
||||
"peak": 20700,
|
||||
"trailing_on": true,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 13,982 도달",
|
||||
"cur_price": 18340,
|
||||
"tranches": 2,
|
||||
"tranche_value": 2517324.6404987266,
|
||||
"last_add_price": 14660
|
||||
},
|
||||
"478560": {
|
||||
"code": "478560",
|
||||
"name": "블랙야크아이앤씨",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 2595,
|
||||
"entry_price": 3193.8670520231212,
|
||||
"entry_at": "2026-09-04T11:14:26.856101+09:00",
|
||||
"stop": 2875,
|
||||
"target": 3831,
|
||||
"peak": 3500,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 3,140 도달",
|
||||
"cur_price": 3345,
|
||||
"tranches": 2,
|
||||
"tranche_value": 4146153.6468727933,
|
||||
"last_add_price": 3255
|
||||
},
|
||||
"372170": {
|
||||
"code": "372170",
|
||||
"name": "윤성에프앤씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 272,
|
||||
"entry_price": 20782.352941176472,
|
||||
"entry_at": "2026-09-16T13:08:39.890765+09:00",
|
||||
"stop": 17966,
|
||||
"target": 26416,
|
||||
"peak": 25500,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 21,341 도달",
|
||||
"cur_price": 23800,
|
||||
"tranches": 2,
|
||||
"tranche_value": 2842559.6395269656,
|
||||
"last_add_price": 21400
|
||||
},
|
||||
"041520": {
|
||||
"code": "041520",
|
||||
"name": "이엘씨",
|
||||
"sources": [
|
||||
"auto"
|
||||
],
|
||||
"qty": 688,
|
||||
"entry_price": 11935.31976744186,
|
||||
"entry_at": "2026-09-16T13:08:40.269206+09:00",
|
||||
"stop": 10788,
|
||||
"target": 14231,
|
||||
"peak": 13200,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 11,955 도달",
|
||||
"cur_price": 12790,
|
||||
"tranches": 2,
|
||||
"tranche_value": 4111186.507374883,
|
||||
"last_add_price": 12170
|
||||
},
|
||||
"025860": {
|
||||
"code": "025860",
|
||||
"name": "남해화학",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 848,
|
||||
"entry_price": 6130,
|
||||
"entry_at": "2026-09-18T09:14:43.475556+09:00",
|
||||
"stop": 5730,
|
||||
"target": 6930,
|
||||
"peak": 6250,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 6,155 도달",
|
||||
"cur_price": 5940,
|
||||
"tranches": 1,
|
||||
"tranche_value": 5205107.041859375,
|
||||
"last_add_price": 6130
|
||||
},
|
||||
"300720": {
|
||||
"code": "300720",
|
||||
"name": "한일시멘트",
|
||||
"sources": [
|
||||
"watch"
|
||||
],
|
||||
"qty": 323,
|
||||
"entry_price": 16110,
|
||||
"entry_at": "2026-09-30T11:40:45.860919+09:00",
|
||||
"stop": 15271,
|
||||
"target": 17789,
|
||||
"peak": 16290,
|
||||
"trailing_on": false,
|
||||
"scaled_out": false,
|
||||
"entry_path": "pullback",
|
||||
"entry_reason": "눌림목 지정가 16,390 도달",
|
||||
"cur_price": 16110,
|
||||
"tranches": 1,
|
||||
"tranche_value": 5207239.454359375,
|
||||
"last_add_price": 16110
|
||||
}
|
||||
},
|
||||
"realized_pnl": -19540851.041000046,
|
||||
"closed_trades": 50,
|
||||
"wins": 5,
|
||||
"peak_equity": 101690998.13850003,
|
||||
"max_drawdown": 0.2049465569225206,
|
||||
"last_exit": {
|
||||
"086790": "2026-07-29",
|
||||
"030000": "2026-09-04",
|
||||
"403870": "2026-07-02",
|
||||
"095610": "2026-09-16",
|
||||
"093370": "2026-07-20",
|
||||
"240810": "2026-07-24",
|
||||
"034730": "2026-07-03",
|
||||
"214450": "2026-09-08",
|
||||
"055550": "2026-07-20",
|
||||
"024060": "2026-07-27",
|
||||
"003680": "2026-08-20",
|
||||
"252990": "2026-09-01",
|
||||
"010950": "2026-08-03",
|
||||
"090850": "2026-08-06",
|
||||
"078930": "2026-09-03",
|
||||
"068270": "2026-09-04",
|
||||
"327260": "2026-09-07",
|
||||
"138040": "2026-09-08",
|
||||
"000720": "2026-09-16",
|
||||
"004370": "2026-09-18",
|
||||
"079650": "2026-09-30"
|
||||
},
|
||||
"created_at": "2026-06-11T09:00:01.944290+09:00",
|
||||
"updated_at": "2026-10-08T15:28:47.147135+09:00"
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
{"ts": "2026-06-17T11:15:02.310687+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 130900, "qty": 47, "cost": 6153223, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 121254, "target": 150191, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "129,300 vs 118,950"}, {"label": "정배열(5>10)", "ok": true, "detail": "120,400 / 118,950"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "120,400 / 118,307"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-13.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +371 · 기 +158 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+22%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.12% (환산) vs 평균 0.38% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 126,500"}, {"label": "거래량 급증", "ok": true, "detail": "3,094,534 (환산) vs 평균 1,031,725"}, {"label": "회전율 급증", "ok": true, "detail": "1.12% (환산) vs 평균 0.38%"}, {"label": "RSI", "ok": true, "detail": "60"}]}
|
||||
{"ts": "2026-06-17T16:47:28.529957+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 62900, "qty": 47, "cost": 2956743, "path": "pullback", "reason": "눌림목 지정가 64,531 도달", "stop": 52447, "target": 83807, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "62,800 vs 53,510"}, {"label": "정배열(5>10)", "ok": true, "detail": "59,880 / 53,510"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "59,880 / 49,831"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -4,434 · 기 +5,040 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+9%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "11.31% vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "9,309,211 vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": true, "detail": "11.31% vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "58"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 64,531 (현재 62,800)"}]}
|
||||
{"ts": "2026-06-19T09:02:06.351442+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19160, "qty": 325, "cost": 6227934, "path": "pullback", "reason": "눌림목 지정가 20,044 도달", "stop": 18100, "target": 21279, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,150 vs 18,963"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,616 / 18,963"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,616 / 19,361"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-31.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,162 · 기 +2,080 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "37,673 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,044 (현재 19,150)"}]}
|
||||
{"ts": "2026-06-19T13:32:02.797519+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 121100, "qty": 47, "proceeds": 5680601, "entry_price": 130900, "pnl": -472622, "pnl_pct": -7.49, "hold_from": "2026-06-17T11:15:02.310686+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "121,254 (현재 121,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 150,191"}, {"label": "추세(10일선)", "ok": true, "detail": "119,670"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +531 · 기 -129"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 134,178"}]}
|
||||
{"ts": "2026-06-23T09:00:09.548616+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18820, "qty": 325, "proceeds": 6104573, "entry_price": 19160, "pnl": -123361, "pnl_pct": -1.77, "hold_from": "2026-06-19T09:02:06.351441+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "18,100 (현재 18,710)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,279"}, {"label": "추세(10일선)", "ok": false, "detail": "18,919"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -318 · 기 -227"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,528"}]}
|
||||
{"ts": "2026-06-23T09:04:35.878315+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 121000, "qty": 49, "cost": 5929889, "path": "pullback", "reason": "눌림목 지정가 122,636 도달", "stop": 111004, "target": 140992, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "121,200 vs 119,680"}, {"label": "정배열(5>10)", "ok": true, "detail": "120,840 / 119,680"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "120,840 / 118,418"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-14.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +310 · 기 -361 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+30%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.12% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": false, "detail": "324,680 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": false, "detail": "0.12% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 122,636 (현재 121,200)"}]}
|
||||
{"ts": "2026-06-24T09:04:06.117082+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 52300, "qty": 47, "proceeds": 2453307, "entry_price": 62900, "pnl": -503437, "pnl_pct": -16.85, "hold_from": "2026-06-17T16:47:28.529950+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "52,447 (현재 52,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 83,807"}, {"label": "추세(10일선)", "ok": false, "detail": "52,460"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +711 · 기 -2,060"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 66,717"}]}
|
||||
{"ts": "2026-06-24T09:04:06.117214+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 156700, "qty": 18, "cost": 2821023, "path": "pullback", "reason": "눌림목 지정가 165,075 도달", "stop": 129363, "target": 211374, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "156,200 vs 128,170"}, {"label": "정배열(5>10)", "ok": true, "detail": "143,260 / 128,170"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "143,260 / 121,703"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+46.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +143 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+6%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "5.83% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "2,860,073 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "5.83% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 165,075 (현재 156,200)"}]}
|
||||
{"ts": "2026-06-24T09:12:05.351845+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 17270, "qty": 199, "cost": 3437246, "path": "pullback", "reason": "눌림목 지정가 17,366 도달", "stop": 14804, "target": 22203, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "17,280 vs 13,750"}, {"label": "정배열(5>10)", "ok": true, "detail": "16,364 / 13,750"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "16,364 / 11,500"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+21.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +1,740 · 기 -508 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+16%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.34% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": false, "detail": "2,509,340 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": false, "detail": "2.34% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "65"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 17,366 (현재 17,280)"}]}
|
||||
{"ts": "2026-06-24T09:46:04.783231+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158200, "qty": 17, "cost": 2689803, "path": "pullback", "reason": "눌림목 지정가 158,264 도달", "stop": 130097, "target": 214407, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "158,000 vs 153,380"}, {"label": "정배열(5>10)", "ok": true, "detail": "172,240 / 153,380"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "172,240 / 99,750"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+56.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +41 · 기 +65 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+5%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.25% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "822,940 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "4.25% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,264 (현재 158,000)"}]}
|
||||
{"ts": "2026-06-24T11:12:05.900560+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 18260, "qty": 188, "cost": 3433395, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 14804, "target": 22203, "signals": [{"label": "손절선", "ok": true, "detail": "14,804 (현재 18,250)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,203"}, {"label": "추세(10일선)", "ok": true, "detail": "13,847"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +1,742 · 기 -508"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 18,094"}]}
|
||||
{"ts": "2026-06-25T09:00:23.676126+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 179100, "qty": 15, "cost": 2686903, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 130097, "target": 214407, "signals": [{"label": "손절선", "ok": true, "detail": "130,097 (현재 178,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 214,407"}, {"label": "추세(10일선)", "ok": true, "detail": "155,410"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -56 · 기 +134"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 167,275"}]}
|
||||
{"ts": "2026-06-25T09:02:04.073952+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 53200, "qty": 49, "cost": 2607191, "path": "pullback", "reason": "눌림목 지정가 63,881 도달", "stop": 43200, "target": 73200, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "53,300 vs 52,560"}, {"label": "정배열(5>10)", "ok": true, "detail": "57,980 / 52,560"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "57,980 / 49,672"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+15.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +406 · 기 -1,461 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+29%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.21% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "1,819,047 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "2.21% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 63,881 (현재 53,300)"}]}
|
||||
{"ts": "2026-06-26T09:00:05.786723+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 50000, "qty": 49, "proceeds": 2445222, "entry_price": 53200, "pnl": -161969, "pnl_pct": -6.02, "hold_from": "2026-06-25T09:02:04.073951+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "43,200 (현재 51,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 73,200"}, {"label": "추세(10일선)", "ok": false, "detail": "52,330"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -311 · 기 -267"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 57,056"}]}
|
||||
{"ts": "2026-06-26T10:06:05.265619+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 110600, "qty": 49, "proceeds": 5408832, "entry_price": 121000, "pnl": -521057, "pnl_pct": -8.6, "hold_from": "2026-06-23T09:04:35.878312+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "111,004 (현재 110,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 140,992"}, {"label": "추세(10일선)", "ok": false, "detail": "118,630"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +34 · 기 -271"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 124,653"}]}
|
||||
{"ts": "2026-06-26T10:14:04.062152+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 166900, "qty": 16, "cost": 2670801, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 129363, "target": 211374, "signals": [{"label": "손절선", "ok": true, "detail": "129,363 (현재 166,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 211,374"}, {"label": "추세(10일선)", "ok": true, "detail": "129,220"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -126 · 기 +320"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 165,659"}]}
|
||||
{"ts": "2026-06-29T11:36:01.266385+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 745000, "qty": 4, "cost": 2980447, "path": "pullback", "reason": "눌림목 지정가 748,914 도달", "stop": 645871, "target": 943257, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "744,000 vs 735,600"}, {"label": "정배열(5>10)", "ok": true, "detail": "766,800 / 735,600"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "766,800 / 530,600"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -194 · 기 +152 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+8%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.35% (환산) vs 평균 0.45% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 883,000"}, {"label": "거래량 급증", "ok": true, "detail": "977,966 (환산) vs 평균 329,863"}, {"label": "회전율 급증", "ok": true, "detail": "1.35% (환산) vs 평균 0.45%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 748,914 (현재 744,000)"}]}
|
||||
{"ts": "2026-06-29T14:18:01.486781+09:00", "side": "BUY", "code": "034730", "name": "SK", "price": 778000, "qty": 4, "cost": 3112467, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 645871, "target": 943257, "signals": [{"label": "손절선", "ok": true, "detail": "645,871 (현재 779,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 943,257"}, {"label": "추세(10일선)", "ok": true, "detail": "739,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -200 · 기 +143"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 778,043"}]}
|
||||
{"ts": "2026-06-30T09:00:05.752645+09:00", "side": "BUY", "code": "403870", "name": "HPSP", "price": 55300, "qty": 43, "cost": 2378257, "path": "pullback", "reason": "눌림목 지정가 63,981 도달", "stop": 44022, "target": 77857, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "53,700 vs 52,600"}, {"label": "정배열(5>10)", "ok": true, "detail": "58,060 / 52,600"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "58,060 / 49,679"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +222 · 기 -821 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+28%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 5.97% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 71,500"}, {"label": "거래량 급증", "ok": false, "detail": "827 (환산) vs 평균 4,912,598"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 5.97%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 63,981 (현재 53,700)"}]}
|
||||
{"ts": "2026-07-01T09:06:03.211367+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 357500, "qty": 15, "cost": 5363304, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 325040, "target": 422420, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "357,000 vs 307,050"}, {"label": "정배열(5>10)", "ok": true, "detail": "317,600 / 307,050"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "317,600 / 305,700"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -75 · 기 +221 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+35%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.89% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "196,247 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.89% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "68"}]}
|
||||
{"ts": "2026-07-01T09:24:02.311832+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 372000, "qty": 14, "cost": 5208781, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 325040, "target": 422420, "signals": [{"label": "손절선", "ok": true, "detail": "325,040 (현재 372,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 422,420"}, {"label": "추세(10일선)", "ok": true, "detail": "308,550"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -75 · 기 +221"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 368,499"}]}
|
||||
{"ts": "2026-07-01T11:06:02.353871+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 212000, "qty": 32, "proceeds": 6770771, "entry_price": 167997, "pnl": 1394065, "pnl_pct": 26.19, "hold_from": "2026-06-24T09:46:04.783230+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "213,193 (현재 213,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "158,880"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -229 · 기 +300"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T10:18:03.834928+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 122200, "qty": 45, "cost": 5499825, "path": "pullback", "reason": "눌림목 지정가 122,215 도달", "stop": 111572, "target": 143456, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "122,000 vs 119,760"}, {"label": "정배열(5>10)", "ok": true, "detail": "121,000 / 119,760"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "121,000 / 118,432"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +22 · 기 -44 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+29%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.80% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": true, "detail": "2,199,199 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": true, "detail": "0.80% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 122,215 (현재 122,000)"}]}
|
||||
{"ts": "2026-07-02T10:40:02.952671+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18960, "qty": 322, "cost": 6106036, "path": "pullback", "reason": "눌림목 지정가 19,970 도달", "stop": 17790, "target": 21300, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,960 vs 18,944"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,578 / 18,944"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,578 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+3.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -339 · 기 +110 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.86% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "986,112 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.86% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 19,970 (현재 18,960)"}]}
|
||||
{"ts": "2026-07-02T11:20:02.056072+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 22550, "qty": 271, "cost": 6111967, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17790, "target": 21300, "signals": [{"label": "손절선", "ok": true, "detail": "17,790 (현재 19,350)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,300"}, {"label": "추세(10일선)", "ok": true, "detail": "18,983"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,350"}]}
|
||||
{"ts": "2026-07-02T11:22:01.965010+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 19000, "qty": 593, "proceeds": 11245029, "entry_price": 20601, "pnl": -972973, "pnl_pct": -7.77, "hold_from": "2026-07-02T10:40:02.952669+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "19,431 (현재 19,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,941"}, {"label": "추세(10일선)", "ok": true, "detail": "18,958"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -339 · 기 +110"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T12:42:02.220043+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 15250, "qty": 387, "proceeds": 5890242, "entry_price": 17751, "pnl": -980399, "pnl_pct": -14.09, "hold_from": "2026-06-24T09:12:05.351843+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "15,280 (현재 15,280)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 22,692"}, {"label": "추세(10일선)", "ok": true, "detail": "13,550"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -1,920 · 기 +434"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T14:30:02.062791+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 134100, "qty": 34, "proceeds": 4550509, "entry_price": 161500, "pnl": -941314, "pnl_pct": -16.97, "hold_from": "2026-06-24T09:04:06.117213+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "134,624 (현재 134,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 215,253"}, {"label": "추세(10일선)", "ok": true, "detail": "125,960"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +395 · 기 -1,516"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-02T15:04:06.185101+09:00", "side": "SELL", "code": "403870", "name": "HPSP", "price": 43900, "qty": 43, "proceeds": 1884019, "entry_price": 55300, "pnl": -494238, "pnl_pct": -20.61, "hold_from": "2026-06-30T09:00:05.752638+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "44,022 (현재 43,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 77,857"}, {"label": "추세(10일선)", "ok": false, "detail": "51,620"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +2,379 · 기 -6,625"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 59,411"}]}
|
||||
{"ts": "2026-07-03T09:02:04.920019+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 133400, "qty": 16, "cost": 2134720, "path": "pullback", "reason": "눌림목 지정가 163,875 도달", "stop": 104263, "target": 191674, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "133,300 vs 125,880"}, {"label": "정배열(5>10)", "ok": true, "detail": "138,680 / 125,880"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "138,680 / 121,322"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+18.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +674 · 기 -2,170 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+31%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.16% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": false, "detail": "571,607 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": false, "detail": "1.16% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 163,875 (현재 133,300)"}]}
|
||||
{"ts": "2026-07-03T09:02:04.920417+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 14660, "qty": 173, "cost": 2536560, "path": "pullback", "reason": "눌림목 지정가 17,179 도달", "stop": 11914, "target": 20152, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,680 vs 13,490"}, {"label": "정배열(5>10)", "ok": true, "detail": "15,844 / 13,490"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,844 / 11,456"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+12.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,591 · 기 +253 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+36%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.92% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": false, "detail": "991,593 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": false, "detail": "0.92% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 17,179 (현재 14,680)"}]}
|
||||
{"ts": "2026-07-03T09:10:03.670622+09:00", "side": "SELL", "code": "034730", "name": "SK", "price": 656000, "qty": 8, "proceeds": 5237766, "entry_price": 761500, "pnl": -855147, "pnl_pct": -13.85, "hold_from": "2026-06-29T11:36:01.266384+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "662,371 (현재 657,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 959,757"}, {"label": "추세(10일선)", "ok": false, "detail": "726,900"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -51 · 기 +3"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T09:50:01.667559+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 328500, "qty": 29, "proceeds": 9507923, "entry_price": 364500, "pnl": -1064162, "pnl_pct": -9.88, "hold_from": "2026-07-01T09:06:03.211365+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "331,504 (현재 331,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 430,491"}, {"label": "추세(10일선)", "ok": true, "detail": "304,450"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -127 · 기 +294"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-03T10:16:02.530929+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 126200, "qty": 44, "cost": 5553633, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111572, "target": 143456, "signals": [{"label": "손절선", "ok": true, "detail": "111,572 (현재 126,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 143,456"}, {"label": "추세(10일선)", "ok": true, "detail": "120,180"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -140 · 기 +278"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 125,493"}]}
|
||||
{"ts": "2026-07-03T10:24:02.772093+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19040, "qty": 309, "cost": 5884243, "path": "pullback", "reason": "눌림목 지정가 20,004 도달", "stop": 17921, "target": 21277, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,030 vs 18,951"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,592 / 18,951"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,592 / 19,359"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -35 · 기 +168 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+34%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.54% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "619,193 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.54% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,004 (현재 19,030)"}]}
|
||||
{"ts": "2026-07-07T09:12:07.266482+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 155900, "qty": 16, "cost": 2494774, "path": "pullback", "reason": "눌림목 지정가 158,314 도달", "stop": 127872, "target": 211957, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "155,800 vs 153,160"}, {"label": "정배열(5>10)", "ok": true, "detail": "171,800 / 153,160"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,800 / 99,713"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+43.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +80 · 기 -197 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+14%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.33% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "645,567 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "3.33% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,314 (현재 155,800)"}]}
|
||||
{"ts": "2026-07-07T12:02:08.180568+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 12230, "qty": 173, "proceeds": 2111664, "entry_price": 14660, "pnl": -424896, "pnl_pct": -16.58, "hold_from": "2026-07-03T09:02:04.920416+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "11,914 (현재 12,230)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,152"}, {"label": "추세(10일선)", "ok": false, "detail": "13,245"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -1,558 · 기 -4"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 15,662"}]}
|
||||
{"ts": "2026-07-07T14:08:32.989792+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 183300, "qty": 11, "cost": 2016602, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 104263, "target": 191674, "signals": [{"label": "손절선", "ok": true, "detail": "104,263 (현재 183,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 191,674"}, {"label": "추세(10일선)", "ok": true, "detail": "123,150"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +263 · 기 -1,543"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 141,825"}]}
|
||||
{"ts": "2026-07-07T14:08:32.992041+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 177000, "qty": 14, "cost": 2478372, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 127872, "target": 211957, "signals": [{"label": "손절선", "ok": true, "detail": "127,872 (현재 177,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 211,957"}, {"label": "추세(10일선)", "ok": true, "detail": "148,710"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +152 · 기 -281"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 165,142"}]}
|
||||
{"ts": "2026-07-07T14:08:32.992753+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 20750, "qty": 283, "cost": 5873131, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17921, "target": 21277, "signals": [{"label": "손절선", "ok": true, "detail": "17,921 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,277"}, {"label": "추세(10일선)", "ok": true, "detail": "18,901"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 +113"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,358"}]}
|
||||
{"ts": "2026-07-08T09:00:17.244123+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 108300, "qty": 27, "proceeds": 2918398, "entry_price": 153730, "pnl": -1232925, "pnl_pct": -29.55, "hold_from": "2026-07-03T09:02:04.920018+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "128,455 (현재 112,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 204,279"}, {"label": "추세(10일선)", "ok": false, "detail": "123,820"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +243 · 기 -857"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-08T09:00:17.245316+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 137900, "qty": 30, "proceeds": 4128933, "entry_price": 165747, "pnl": -844213, "pnl_pct": -16.8, "hold_from": "2026-07-07T09:12:07.266481+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "138,020 (현재 137,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 221,200"}, {"label": "추세(10일선)", "ok": false, "detail": "151,370"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +458 · 기 -572"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-08T09:12:05.963550+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18900, "qty": 592, "proceeds": 11166982, "entry_price": 19857, "pnl": -590392, "pnl_pct": -4.82, "hold_from": "2026-07-03T10:24:02.772092+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "18,903 (현재 18,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,766"}, {"label": "추세(10일선)", "ok": false, "detail": "18,938"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +193 · 기 +193"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-09T09:02:04.459886+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19110, "qty": 297, "cost": 5676521, "path": "pullback", "reason": "눌림목 지정가 20,021 도달", "stop": 18017, "target": 21296, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,070 vs 18,955"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,600 / 18,955"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,600 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+0.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -10 · 기 +164 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.08% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "93,540 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.08% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "50"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,021 (현재 19,070)"}]}
|
||||
{"ts": "2026-07-09T09:36:03.454534+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 132000, "qty": 40, "cost": 5280792, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 120761, "target": 154477, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "131,900 vs 108,660"}, {"label": "정배열(5>10)", "ok": true, "detail": "115,560 / 108,660"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,560 / 113,228"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+22.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -184 · 기 +964 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.91% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "1,026,867 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.91% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-10T09:00:10.959018+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158000, "qty": 15, "cost": 2370356, "path": "pullback", "reason": "눌림목 지정가 157,800 도달", "stop": 129200, "target": 215600, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "157,000 vs 153,280"}, {"label": "정배열(5>10)", "ok": true, "detail": "172,040 / 153,280"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "172,040 / 99,733"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+53.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +452 · 기 -360 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+13%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.95% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "763,807 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "3.95% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 157,800 (현재 157,000)"}]}
|
||||
{"ts": "2026-07-10T09:58:09.974240+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 168200, "qty": 14, "cost": 2355153, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 129200, "target": 215600, "signals": [{"label": "손절선", "ok": true, "detail": "129,200 (현재 168,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 215,600"}, {"label": "추세(10일선)", "ok": true, "detail": "154,390"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +452 · 기 -360"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 167,600"}]}
|
||||
{"ts": "2026-07-10T11:06:06.467292+09:00", "side": "BUY", "code": "093370", "name": "후성", "price": 13660, "qty": 151, "cost": 2062969, "path": "pullback", "reason": "눌림목 지정가 16,984 도달", "stop": 10621, "target": 19737, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,630 vs 13,385"}, {"label": "정배열(5>10)", "ok": true, "detail": "15,634 / 13,385"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "15,634 / 11,439"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +210 · 기 +239 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+47%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "14.23% (환산) vs 평균 4.77% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 20,050"}, {"label": "거래량 급증", "ok": true, "detail": "15,267,605 (환산) vs 평균 5,114,125"}, {"label": "회전율 급증", "ok": true, "detail": "14.23% (환산) vs 평균 4.77%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 16,984 (현재 13,630)"}]}
|
||||
{"ts": "2026-07-10T12:14:02.668438+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 126200, "qty": 15, "cost": 1893284, "path": "pullback", "reason": "눌림목 지정가 162,268 도달", "stop": 96000, "target": 186600, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "126,200 vs 125,170"}, {"label": "정배열(5>10)", "ok": true, "detail": "137,260 / 125,170"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "137,260 / 121,203"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+17.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +483 · 기 -241 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+38%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "7.53% (환산) vs 평균 2.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 183,300"}, {"label": "거래량 급증", "ok": true, "detail": "3,695,762 (환산) vs 평균 1,234,482"}, {"label": "회전율 급증", "ok": true, "detail": "7.53% (환산) vs 평균 2.51%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 162,268 (현재 126,200)"}]}
|
||||
{"ts": "2026-07-13T09:00:09.988994+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 136900, "qty": 39, "cost": 5339901, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 120761, "target": 154477, "signals": [{"label": "손절선", "ok": true, "detail": "120,761 (현재 136,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,477"}, {"label": "추세(10일선)", "ok": true, "detail": "109,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -64 · 기 +919"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,864"}]}
|
||||
{"ts": "2026-07-13T09:12:04.296873+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 341500, "qty": 15, "cost": 5123268, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 310647, "target": 403206, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "341,000 vs 305,450"}, {"label": "정배열(5>10)", "ok": true, "detail": "314,400 / 305,450"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "314,400 / 305,433"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -63 · 기 +144 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.90% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "197,687 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "1.90% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "64"}]}
|
||||
{"ts": "2026-07-13T09:26:03.407256+09:00", "side": "BUY", "code": "240810", "name": "원익IPS", "price": 139100, "qty": 13, "cost": 1808571, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 96000, "target": 186600, "signals": [{"label": "손절선", "ok": true, "detail": "96,000 (현재 139,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 186,600"}, {"label": "추세(10일선)", "ok": true, "detail": "126,460"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -213 · 기 +772"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 136,162"}]}
|
||||
{"ts": "2026-07-13T10:04:06.408909+09:00", "side": "BUY", "code": "055550", "name": "신한지주", "price": 113100, "qty": 51, "cost": 5768965, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 104723, "target": 129854, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "113,050 vs 100,265"}, {"label": "정배열(5>10)", "ok": true, "detail": "101,790 / 100,265"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "101,790 / 96,676"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+31.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -368 · 기 +1,005 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.71% (환산) vs 평균 0.35% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 109,800"}, {"label": "거래량 급증", "ok": true, "detail": "3,366,381 (환산) vs 평균 1,653,581"}, {"label": "회전율 급증", "ok": true, "detail": "0.71% (환산) vs 평균 0.35%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-14T10:20:06.143905+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 310500, "qty": 15, "proceeds": 4648418, "entry_price": 341500, "pnl": -474851, "pnl_pct": -9.08, "hold_from": "2026-07-13T09:12:04.296871+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "310,647 (현재 310,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 403,206"}, {"label": "추세(10일선)", "ok": true, "detail": "302,400"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -50 · 기 +82"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 351,713"}]}
|
||||
{"ts": "2026-07-16T09:00:08.347687+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 209000, "qty": 29, "proceeds": 6049181, "entry_price": 162924, "pnl": 1323672, "pnl_pct": 28.28, "hold_from": "2026-07-10T09:00:10.959008+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "210,764 (현재 207,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "158,280"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -799 · 기 +891"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-16T09:00:08.349387+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18580, "qty": 297, "proceeds": 5507499, "entry_price": 19110, "pnl": -169022, "pnl_pct": -2.77, "hold_from": "2026-07-09T09:02:04.459884+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "18,017 (현재 18,560)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,296"}, {"label": "추세(10일선)", "ok": false, "detail": "18,904"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -120 · 기 -150"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,486"}]}
|
||||
{"ts": "2026-07-20T09:02:02.272710+09:00", "side": "SELL", "code": "055550", "name": "신한지주", "price": 104000, "qty": 51, "proceeds": 5293657, "entry_price": 113100, "pnl": -475308, "pnl_pct": -8.05, "hold_from": "2026-07-13T10:04:06.408908+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "104,723 (현재 104,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 129,854"}, {"label": "추세(10일선)", "ok": true, "detail": "99,380"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -111 · 기 +265"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 115,714"}]}
|
||||
{"ts": "2026-07-20T10:50:03.214952+09:00", "side": "SELL", "code": "093370", "name": "후성", "price": 10600, "qty": 151, "proceeds": 1597479, "entry_price": 13660, "pnl": -465491, "pnl_pct": -22.4, "hold_from": "2026-07-10T11:06:06.467291+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "10,621 (현재 10,610)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 19,737"}, {"label": "추세(10일선)", "ok": false, "detail": "12,043"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -893 · 기 +516"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,615"}]}
|
||||
{"ts": "2026-07-21T12:06:01.166188+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 344500, "qty": 14, "cost": 4823723, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 313808, "target": 405884, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "344,500 vs 305,800"}, {"label": "정배열(5>10)", "ok": true, "detail": "315,100 / 305,800"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "315,100 / 305,492"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+39.7%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -7 · 기 +1 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+43%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.38% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "247,158 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "2.38% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "65"}]}
|
||||
{"ts": "2026-07-22T14:18:05.421715+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14030, "qty": 206, "cost": 2890614, "path": "pullback", "reason": "눌림목 지정가 14,023 도달", "stop": 11815, "target": 18460, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,020 vs 12,975"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,552 / 12,975"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,552 / 13,283"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+10.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -1,005 · 기 +1,723 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.97% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "585,754 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.97% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "53"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 14,023 (현재 14,020)"}]}
|
||||
{"ts": "2026-07-22T15:04:00.992935+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 158100, "qty": 16, "cost": 2529979, "path": "pullback", "reason": "눌림목 지정가 158,107 도달", "stop": 129761, "target": 214778, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "158,000 vs 153,380"}, {"label": "정배열(5>10)", "ok": true, "detail": "172,240 / 153,380"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "172,240 / 99,750"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+49.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -311 · 기 +22 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+24%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "5.02% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "971,360 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": true, "detail": "5.02% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,107 (현재 158,000)"}]}
|
||||
{"ts": "2026-07-23T09:50:01.510936+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 88200, "qty": 57, "cost": 5028154, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 80223, "target": 104155, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "88,200 vs 80,900"}, {"label": "정배열(5>10)", "ok": true, "detail": "83,000 / 80,900"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,000 / 75,058"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+47.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -150 · 기 +299 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+17%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.85% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "785,587 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.85% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-07-23T10:24:01.500387+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 152100, "qty": 16, "proceeds": 2428854, "entry_price": 158100, "pnl": -101125, "pnl_pct": -3.8, "hold_from": "2026-07-22T15:04:00.992934+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "129,761 (현재 152,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 214,778"}, {"label": "추세(10일선)", "ok": false, "detail": "152,780"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -101 · 기 -107"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 167,575"}]}
|
||||
{"ts": "2026-07-23T10:30:02.480604+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 14140, "qty": 251, "cost": 3549672, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 12315, "target": 17791, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "14,180 vs 12,429"}, {"label": "정배열(5>10)", "ok": true, "detail": "13,154 / 12,429"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "13,154 / 12,687"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+55.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +109 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "84.26% (환산) vs 평균 31.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 13,990"}, {"label": "거래량 급증", "ok": true, "detail": "12,639,979 (환산) vs 평균 4,720,532"}, {"label": "회전율 급증", "ok": true, "detail": "84.26% (환산) vs 평균 31.47%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-07-23T12:22:02.591407+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 356000, "qty": 14, "cost": 4984748, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 313808, "target": 405884, "signals": [{"label": "손절선", "ok": true, "detail": "313,808 (현재 356,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 405,884"}, {"label": "추세(10일선)", "ok": true, "detail": "306,950"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +6 · 기 +26"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 355,034"}]}
|
||||
{"ts": "2026-07-23T12:54:04.493271+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 90900, "qty": 55, "cost": 5000250, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 80223, "target": 104155, "signals": [{"label": "손절선", "ok": true, "detail": "80,223 (현재 91,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 104,155"}, {"label": "추세(10일선)", "ok": true, "detail": "81,180"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -169 · 기 +311"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 90,945"}]}
|
||||
{"ts": "2026-07-24T09:00:05.732126+09:00", "side": "SELL", "code": "240810", "name": "원익IPS", "price": 111800, "qty": 28, "proceeds": 3124296, "entry_price": 132189, "pnl": -577559, "pnl_pct": -15.42, "hold_from": "2026-07-10T12:14:02.668437+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "102,302 (현재 113,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 191,963"}, {"label": "추세(10일선)", "ok": false, "detail": "123,880"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -480 · 기 -206"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-24T09:00:05.734614+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 155500, "qty": 16, "cost": 2488373, "path": "pullback", "reason": "눌림목 지정가 158,093 도달", "stop": 127139, "target": 212221, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "155,000 vs 153,080"}, {"label": "정배열(5>10)", "ok": true, "detail": "171,640 / 153,080"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,640 / 99,700"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+44.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +200 · 기 -454 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+27%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "3.77% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "730,327 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": false, "detail": "3.77% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 158,093 (현재 155,000)"}]}
|
||||
{"ts": "2026-07-24T09:02:04.039961+09:00", "side": "BUY", "code": "024060", "name": "흥구석유", "price": 15060, "qty": 236, "cost": 3554693, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 12315, "target": 17791, "signals": [{"label": "손절선", "ok": true, "detail": "12,315 (현재 15,020)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,791"}, {"label": "추세(10일선)", "ok": true, "detail": "12,513"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +208 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,766"}]}
|
||||
{"ts": "2026-07-24T09:28:04.946271+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14510, "qty": 200, "cost": 2902435, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11815, "target": 18460, "signals": [{"label": "손절선", "ok": true, "detail": "11,815 (현재 15,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,460"}, {"label": "추세(10일선)", "ok": true, "detail": "13,113"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -857 · 기 +1,172"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,791"}]}
|
||||
{"ts": "2026-07-24T10:02:04.395802+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18950, "qty": 306, "cost": 5799570, "path": "pullback", "reason": "눌림목 지정가 20,012 도달", "stop": 17843, "target": 21164, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "18,950 vs 18,943"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,576 / 18,943"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,576 / 19,358"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+19.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +2 · 기 -5 (천주)"}, {"label": "투자의견", "ok": true, "detail": "4.0/5"}, {"label": "상승여력", "ok": true, "detail": "+33%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.39% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "454,069 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.39% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,012 (현재 18,950)"}]}
|
||||
{"ts": "2026-07-27T09:04:05.309208+09:00", "side": "SELL", "code": "024060", "name": "흥구석유", "price": 12200, "qty": 487, "proceeds": 5929814, "entry_price": 14586, "pnl": -1174551, "pnl_pct": -16.36, "hold_from": "2026-07-23T10:30:02.480603+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "12,708 (현재 12,250)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,342"}, {"label": "추세(10일선)", "ok": true, "detail": "12,236"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -85 · 기 +2"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-27T09:06:01.452072+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7720, "qty": 547, "cost": 4223473, "path": "pullback", "reason": "눌림목 지정가 7,907 도달", "stop": 6890, "target": 9380, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "7,720 vs 5,416"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,502 / 5,416"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,502 / 4,893"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+88.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +49 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.17% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "258,673 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "4.17% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": false, "detail": "78"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,907 (현재 7,720)"}]}
|
||||
{"ts": "2026-07-27T12:50:05.820784+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 8080, "qty": 523, "cost": 4226474, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 6890, "target": 9380, "signals": [{"label": "손절선", "ok": true, "detail": "6,890 (현재 8,070)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,380"}, {"label": "추세(10일선)", "ok": true, "detail": "5,452"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +50 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,997"}]}
|
||||
{"ts": "2026-07-27T13:34:01.435246+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 7050, "qty": 1070, "proceeds": 7528790, "entry_price": 7896, "pnl": -921157, "pnl_pct": -10.71, "hold_from": "2026-07-27T09:06:01.452056+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "7,066 (현재 7,050)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,556"}, {"label": "추세(10일선)", "ok": true, "detail": "5,350"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +50 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-28T09:00:15.935347+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6780, "qty": 510, "cost": 3458319, "path": "pullback", "reason": "눌림목 지정가 7,884 도달", "stop": 5916, "target": 8509, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "7,050 vs 5,350"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,368 / 5,350"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,368 / 4,882"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+73.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +86 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.01% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "813 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "0.01% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "69"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,884 (현재 7,050)"}]}
|
||||
{"ts": "2026-07-28T09:02:04.747643+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 11920, "qty": 406, "proceeds": 4830083, "entry_price": 14266, "pnl": -962966, "pnl_pct": -16.45, "hold_from": "2026-07-22T14:18:05.421714+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "11,984 (현재 11,960)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,831"}, {"label": "추세(10일선)", "ok": false, "detail": "12,769"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +84 · 기 -61"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-28T09:04:02.736929+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 123200, "qty": 16, "proceeds": 1967356, "entry_price": 155500, "pnl": -521017, "pnl_pct": -20.77, "hold_from": "2026-07-24T09:00:05.734608+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "127,139 (현재 123,600)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 212,221"}, {"label": "추세(10일선)", "ok": false, "detail": "149,940"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +269 · 기 -554"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 166,050"}]}
|
||||
{"ts": "2026-07-28T09:10:01.739939+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 7100, "qty": 487, "cost": 3458219, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5916, "target": 8509, "signals": [{"label": "손절선", "ok": true, "detail": "5,916 (현재 7,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,509"}, {"label": "추세(10일선)", "ok": true, "detail": "5,354"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +86 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,079"}]}
|
||||
{"ts": "2026-07-29T09:02:02.224639+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 117900, "qty": 45, "cost": 5306296, "path": "pullback", "reason": "눌림목 지정가 118,115 도달", "stop": 111200, "target": 131300, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "117,800 vs 116,390"}, {"label": "정배열(5>10)", "ok": true, "detail": "117,020 / 116,390"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "117,020 / 109,982"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+45.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +39 · 기 +307 (천주)"}, {"label": "투자의견", "ok": true, "detail": "3.8/5"}, {"label": "상승여력", "ok": true, "detail": "+26%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.03% (환산) vs 평균 0.17% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 123,300"}, {"label": "거래량 급증", "ok": false, "detail": "44,660 (환산) vs 평균 289,522"}, {"label": "회전율 급증", "ok": false, "detail": "0.03% (환산) vs 평균 0.17%"}, {"label": "RSI", "ok": true, "detail": "57"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 118,115 (현재 117,800)"}]}
|
||||
{"ts": "2026-07-29T09:16:04.462967+09:00", "side": "BUY", "code": "138040", "name": "메리츠금융지주", "price": 120500, "qty": 44, "cost": 5302795, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111200, "target": 131300, "signals": [{"label": "손절선", "ok": true, "detail": "111,200 (현재 120,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 131,300"}, {"label": "추세(10일선)", "ok": true, "detail": "116,660"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +39 · 기 +307"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 120,492"}]}
|
||||
{"ts": "2026-07-29T09:24:09.111290+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 18950, "qty": 306, "cost": 5799570, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17843, "target": 21164, "signals": [{"label": "손절선", "ok": true, "detail": "17,843 (현재 20,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,164"}, {"label": "추세(10일선)", "ok": true, "detail": "18,901"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +99 · 기 +138"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,268"}]}
|
||||
{"ts": "2026-07-29T09:24:09.112024+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 128600, "qty": 79, "proceeds": 10139589, "entry_price": 134419, "pnl": -481104, "pnl_pct": -4.33, "hold_from": "2026-07-09T09:36:03.454533+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "122,827 (현재 116,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 157,603"}, {"label": "추세(10일선)", "ok": true, "detail": "105,620"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +245"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T09:28:01.380944+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6390, "qty": 843, "cost": 5387578, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5998, "target": 7174, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,380 vs 5,591"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,742 / 5,591"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,742 / 5,622"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+43.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +73 · 기 -3 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.62% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "134,413 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.62% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "73"}]}
|
||||
{"ts": "2026-07-29T10:42:05.409278+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5980, "qty": 843, "proceeds": 5031310, "entry_price": 6390, "pnl": -356268, "pnl_pct": -6.42, "hold_from": "2026-07-29T09:28:01.380943+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,998 (현재 5,980)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,174"}, {"label": "추세(10일선)", "ok": true, "detail": "5,551"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +77 · 기 -3"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,521"}]}
|
||||
{"ts": "2026-07-29T11:18:04.421952+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 6000, "qty": 997, "proceeds": 5970335, "entry_price": 6936, "pnl": -946202, "pnl_pct": -13.5, "hold_from": "2026-07-28T09:00:15.935337+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "6,040 (현재 6,020)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,729"}, {"label": "추세(10일선)", "ok": true, "detail": "5,246"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +185 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-29T12:04:01.685681+09:00", "side": "SELL", "code": "086790", "name": "하나금융지주", "price": 119200, "qty": 89, "proceeds": 10588113, "entry_price": 124178, "pnl": -465345, "pnl_pct": -4.01, "hold_from": "2026-07-02T10:18:03.834926+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "114,299 (현재 119,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 143,934"}, {"label": "추세(10일선)", "ok": false, "detail": "119,490"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -11 · 기 -51"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-30T09:04:07.455793+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 121300, "qty": 42, "cost": 5095364, "path": "pullback", "reason": "눌림목 지정가 122,665 도달", "stop": 111347, "target": 141206, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "121,200 vs 119,680"}, {"label": "정배열(5>10)", "ok": true, "detail": "120,840 / 119,680"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "120,840 / 118,418"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+34.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -214 · 기 +182 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+36%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.18% (환산) vs 평균 0.37% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 130,700"}, {"label": "거래량 급증", "ok": false, "detail": "494,880 (환산) vs 평균 1,026,357"}, {"label": "회전율 급증", "ok": false, "detail": "0.18% (환산) vs 평균 0.37%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 122,665 (현재 121,200)"}]}
|
||||
{"ts": "2026-07-30T09:04:07.457329+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6130, "qty": 423, "cost": 2593379, "path": "pullback", "reason": "눌림목 지정가 7,792 도달", "stop": 5127, "target": 8135, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,120 vs 5,256"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,182 / 5,256"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,182 / 4,866"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+65.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +234 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "4.31% (환산) vs 평균 5.69% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 8,460"}, {"label": "거래량 급증", "ok": false, "detail": "267,687 (환산) vs 평균 353,452"}, {"label": "회전율 급증", "ok": false, "detail": "4.31% (환산) vs 평균 5.69%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 7,792 (현재 6,120)"}]}
|
||||
{"ts": "2026-07-30T09:16:11.909415+09:00", "side": "BUY", "code": "086790", "name": "하나금융지주", "price": 125200, "qty": 41, "cost": 5133970, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 111347, "target": 141206, "signals": [{"label": "손절선", "ok": true, "detail": "111,347 (현재 125,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 141,206"}, {"label": "추세(10일선)", "ok": true, "detail": "120,100"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -214 · 기 +182"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 124,618"}]}
|
||||
{"ts": "2026-07-30T09:24:07.511684+09:00", "side": "BUY", "code": "010950", "name": "S-Oil", "price": 131800, "qty": 38, "cost": 5009151, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 120690, "target": 154020, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "132,000 vs 108,670"}, {"label": "정배열(5>10)", "ok": true, "detail": "115,580 / 108,670"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "115,580 / 113,230"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+51.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -6 · 기 +178 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+20%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.66% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 116,900"}, {"label": "거래량 급증", "ok": true, "detail": "738,813 (환산) vs 평균 359,705"}, {"label": "회전율 급증", "ok": true, "detail": "0.66% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-07-30T09:26:07.458683+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 6300, "qty": 855, "cost": 5387308, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5917, "target": 7067, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,280 vs 5,581"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,722 / 5,581"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,722 / 5,620"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+46.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +87 · 기 +6 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.51% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "109,080 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.51% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "71"}]}
|
||||
{"ts": "2026-07-31T09:04:07.865082+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 69100, "qty": 112, "proceeds": 7724109, "entry_price": 89526, "pnl": -2304295, "pnl_pct": -22.82, "hold_from": "2026-07-23T09:50:01.510934+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "81,291 (현재 79,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 105,995"}, {"label": "추세(10일선)", "ok": false, "detail": "80,070"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -179 · 기 +325"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-07-31T09:06:06.999372+09:00", "side": "BUY", "code": "003680", "name": "한성기업", "price": 6550, "qty": 396, "cost": 2594189, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5127, "target": 8135, "signals": [{"label": "손절선", "ok": true, "detail": "5,127 (현재 6,640)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,135"}, {"label": "추세(10일선)", "ok": true, "detail": "5,308"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +355 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,453"}]}
|
||||
{"ts": "2026-08-03T09:34:08.538141+09:00", "side": "SELL", "code": "010950", "name": "S-Oil", "price": 120300, "qty": 38, "proceeds": 4562486, "entry_price": 131800, "pnl": -446665, "pnl_pct": -8.73, "hold_from": "2026-07-30T09:24:07.511679+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "120,690 (현재 120,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 154,020"}, {"label": "추세(10일선)", "ok": true, "detail": "107,510"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -35 · 기 +48"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 135,311"}]}
|
||||
{"ts": "2026-08-03T12:00:11.789230+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 12910, "qty": 345, "cost": 4454618, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 11713, "target": 15304, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "12,930 vs 12,003"}, {"label": "정배열(5>10)", "ok": true, "detail": "12,138 / 12,003"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "12,138 / 11,924"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+27.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +7 · 기 +10 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+74%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.72% (환산) vs 평균 0.34% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 12,870"}, {"label": "거래량 급증", "ok": true, "detail": "208,994 (환산) vs 평균 99,303"}, {"label": "회전율 급증", "ok": true, "detail": "0.72% (환산) vs 평균 0.34%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-03T12:02:09.735696+09:00", "side": "BUY", "code": "194370", "name": "제이에스코퍼레이션", "price": 13420, "qty": 332, "cost": 4456108, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11713, "target": 15304, "signals": [{"label": "손절선", "ok": true, "detail": "11,713 (현재 13,450)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 15,304"}, {"label": "추세(10일선)", "ok": true, "detail": "12,055"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +7 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 13,339"}]}
|
||||
{"ts": "2026-08-04T09:50:09.564920+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 90400, "qty": 50, "cost": 4520678, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 82166, "target": 106869, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "90,300 vs 81,110"}, {"label": "정배열(5>10)", "ok": true, "detail": "83,420 / 81,110"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "83,420 / 75,093"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+56.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +68 · 기 +117 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+14%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.87% (환산) vs 평균 0.42% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 87,600"}, {"label": "거래량 급증", "ok": true, "detail": "805,693 (환산) vs 평균 392,182"}, {"label": "회전율 급증", "ok": true, "detail": "0.87% (환산) vs 평균 0.42%"}, {"label": "RSI", "ok": true, "detail": "69"}]}
|
||||
{"ts": "2026-08-04T10:32:12.762882+09:00", "side": "BUY", "code": "078930", "name": "GS", "price": 93500, "qty": 48, "cost": 4488673, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 82166, "target": 106869, "signals": [{"label": "손절선", "ok": true, "detail": "82,166 (현재 93,400)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 106,869"}, {"label": "추세(10일선)", "ok": true, "detail": "81,420"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +57 · 기 +117"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 93,241"}]}
|
||||
{"ts": "2026-08-04T13:26:12.235766+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5910, "qty": 855, "proceeds": 5043197, "entry_price": 6300, "pnl": -344111, "pnl_pct": -6.19, "hold_from": "2026-07-30T09:26:07.458674+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,917 (현재 5,910)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 7,067"}, {"label": "추세(10일선)", "ok": true, "detail": "5,544"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +36 · 기 +9"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 6,419"}]}
|
||||
{"ts": "2026-08-05T09:02:13.217011+09:00", "side": "BUY", "code": "090850", "name": "현대이지웰", "price": 5710, "qty": 911, "cost": 5202590, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5372, "target": 6387, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "5,730 vs 5,526"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,612 / 5,526"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,612 / 5,611"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+11.9%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +14 · 기 -2 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.46% (환산) vs 평균 0.22% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 5,700"}, {"label": "거래량 급증", "ok": true, "detail": "99,627 (환산) vs 평균 49,512"}, {"label": "회전율 급증", "ok": true, "detail": "0.46% (환산) vs 평균 0.22%"}, {"label": "RSI", "ok": true, "detail": "61"}]}
|
||||
{"ts": "2026-08-06T09:00:18.887672+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18730, "qty": 612, "proceeds": 11440408, "entry_price": 18950, "pnl": -158732, "pnl_pct": -1.16, "hold_from": "2026-07-24T10:02:04.395801+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,996 (현재 18,750)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 20,858"}, {"label": "추세(10일선)", "ok": false, "detail": "18,923"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -203 · 기 -147"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-06T09:00:18.926770+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13300, "qty": 181, "cost": 2407661, "path": "pullback", "reason": "눌림목 지정가 13,975 도달", "stop": 11012, "target": 17875, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,300 vs 12,903"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,408 / 12,903"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,408 / 13,271"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-5.1%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -352 · 기 +499 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.00% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "7 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.00% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,975 (현재 13,300)"}]}
|
||||
{"ts": "2026-08-06T09:06:09.886971+09:00", "side": "SELL", "code": "090850", "name": "현대이지웰", "price": 5470, "qty": 911, "proceeds": 4973453, "entry_price": 5710, "pnl": -229137, "pnl_pct": -4.2, "hold_from": "2026-08-05T09:02:13.217002+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "5,372 (현재 5,490)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 6,387"}, {"label": "추세(10일선)", "ok": false, "detail": "5,502"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -30 · 기 -23"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 5,823"}]}
|
||||
{"ts": "2026-08-06T09:34:15.869494+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 192000, "qty": 27, "cost": 5184778, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 179508, "target": 216984, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "191,800 vs 180,670"}, {"label": "정배열(5>10)", "ok": true, "detail": "186,140 / 180,670"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "186,140 / 176,671"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+20.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +255 · 기 -319 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+44%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.67% (환산) vs 평균 0.32% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 191,700"}, {"label": "거래량 급증", "ok": true, "detail": "1,582,767 (환산) vs 평균 738,783"}, {"label": "회전율 급증", "ok": true, "detail": "0.67% (환산) vs 평균 0.32%"}, {"label": "RSI", "ok": true, "detail": "63"}]}
|
||||
{"ts": "2026-08-07T09:02:12.155244+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14290, "qty": 169, "cost": 2415372, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11012, "target": 17875, "signals": [{"label": "손절선", "ok": true, "detail": "11,012 (현재 14,240)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 17,875"}, {"label": "추세(10일선)", "ok": true, "detail": "12,997"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -216 · 기 +313"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,046"}]}
|
||||
{"ts": "2026-08-07T09:12:15.674263+09:00", "side": "BUY", "code": "068270", "name": "셀트리온", "price": 196400, "qty": 26, "cost": 5107166, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 179508, "target": 216984, "signals": [{"label": "손절선", "ok": true, "detail": "179,508 (현재 196,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 216,984"}, {"label": "추세(10일선)", "ok": true, "detail": "181,120"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +338 · 기 +73"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 196,264"}]}
|
||||
{"ts": "2026-08-12T09:30:12.531219+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 400000, "qty": 28, "proceeds": 11178160, "entry_price": 350250, "pnl": 1369689, "pnl_pct": 14.2, "hold_from": "2026-07-21T12:06:01.166187+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "399,647 (현재 399,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "311,300"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -6 · 기 +72"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-12T09:38:12.813434+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19050, "qty": 282, "cost": 5372906, "path": "pullback", "reason": "눌림목 지정가 20,036 도달", "stop": 17979, "target": 21193, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,040 vs 18,952"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,594 / 18,952"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,594 / 19,360"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+2.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +113 · 기 +20 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+32%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.22% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "250,373 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.22% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "49"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,036 (현재 19,040)"}]}
|
||||
{"ts": "2026-08-13T09:00:31.755564+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18880, "qty": 282, "proceeds": 5313778, "entry_price": 19050, "pnl": -59128, "pnl_pct": -0.89, "hold_from": "2026-08-12T09:38:12.813428+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "17,979 (현재 18,930)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,193"}, {"label": "추세(10일선)", "ok": false, "detail": "18,941"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -50 · 기 -76"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,411"}]}
|
||||
{"ts": "2026-08-13T09:08:17.368056+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 402500, "qty": 11, "cost": 4428164, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 365165, "target": 477170, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "402,000 vs 311,550"}, {"label": "정배열(5>10)", "ok": true, "detail": "326,600 / 311,550"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "326,600 / 306,450"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+32.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +27 · 기 +23 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+26%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "2.20% (환산) vs 평균 0.93% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 334,500"}, {"label": "거래량 급증", "ok": true, "detail": "228,347 (환산) vs 평균 96,436"}, {"label": "회전율 급증", "ok": true, "detail": "2.20% (환산) vs 평균 0.93%"}, {"label": "RSI", "ok": true, "detail": "75"}]}
|
||||
{"ts": "2026-08-13T14:02:17.124172+09:00", "side": "BUY", "code": "214450", "name": "파마리서치", "price": 415500, "qty": 11, "cost": 4571186, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 365165, "target": 477170, "signals": [{"label": "손절선", "ok": true, "detail": "365,165 (현재 415,500)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 477,170"}, {"label": "추세(10일선)", "ok": true, "detail": "312,900"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +43 · 기 +19"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 415,052"}]}
|
||||
{"ts": "2026-08-20T10:00:18.515203+09:00", "side": "SELL", "code": "003680", "name": "한성기업", "price": 5360, "qty": 819, "proceeds": 4381280, "entry_price": 6333, "pnl": -806288, "pnl_pct": -15.36, "hold_from": "2026-07-30T09:04:07.457322+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,363 (현재 5,360)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 8,274"}, {"label": "추세(10일선)", "ok": true, "detail": "5,180"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +53 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-08-20T10:34:23.489981+09:00", "side": "BUY", "code": "327260", "name": "RF머트리얼즈", "price": 35100, "qty": 54, "cost": 1895684, "path": "pullback", "reason": "눌림목 지정가 35,709 도달", "stop": 27388, "target": 50523, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "35,050 vs 28,827"}, {"label": "정배열(5>10)", "ok": true, "detail": "35,510 / 28,827"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "35,510 / 31,320"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+29.3%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -51 · 기 +8 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+94%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "11.17% (환산) vs 평균 3.74% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 45,300"}, {"label": "거래량 급증", "ok": true, "detail": "1,004,500 (환산) vs 평균 409,191"}, {"label": "회전율 급증", "ok": true, "detail": "11.17% (환산) vs 평균 3.74%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 35,709 (현재 35,050)"}]}
|
||||
{"ts": "2026-09-01T09:02:25.883275+09:00", "side": "SELL", "code": "252990", "name": "샘씨엔에스", "price": 12660, "qty": 350, "proceeds": 4422360, "entry_price": 13778, "pnl": -400674, "pnl_pct": -8.11, "hold_from": "2026-08-06T09:00:18.926753+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "11,541 (현재 12,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,253"}, {"label": "추세(10일선)", "ok": false, "detail": "12,846"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -53 · 기 -347"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-01T09:10:27.217699+09:00", "side": "BUY", "code": "030000", "name": "제일기획", "price": 19310, "qty": 281, "cost": 5426924, "path": "pullback", "reason": "눌림목 지정가 20,043 도달", "stop": 18249, "target": 21431, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "19,310 vs 18,979"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,648 / 18,979"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,648 / 19,364"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-9.8%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -11 · 기 +18 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+30%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.16% (환산) vs 평균 0.62% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 21,150"}, {"label": "거래량 급증", "ok": false, "detail": "182,700 (환산) vs 평균 711,709"}, {"label": "회전율 급증", "ok": false, "detail": "0.16% (환산) vs 평균 0.62%"}, {"label": "RSI", "ok": true, "detail": "52"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 20,043 (현재 19,310)"}]}
|
||||
{"ts": "2026-09-03T09:00:33.152755+09:00", "side": "SELL", "code": "078930", "name": "GS", "price": 119500, "qty": 98, "proceeds": 11688164, "entry_price": 91918, "pnl": 2678812, "pnl_pct": 30.01, "hold_from": "2026-08-04T09:50:09.564914+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "119,430 (현재 119,100)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "83,990"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +156 · 기 -35"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-03T09:02:24.991073+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 13520, "qty": 186, "cost": 2515097, "path": "pullback", "reason": "눌림목 지정가 13,982 도달", "stop": 11243, "target": 18074, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "13,430 vs 12,916"}, {"label": "정배열(5>10)", "ok": true, "detail": "14,434 / 12,916"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "14,434 / 13,273"}, {"label": "상대강도(시장대비)", "ok": false, "detail": "-9.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -6 · 기 +7 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+86%"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.08% (환산) vs 평균 1.47% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 15,870"}, {"label": "거래량 급증", "ok": false, "detail": "47,427 (환산) vs 평균 882,736"}, {"label": "회전율 급증", "ok": false, "detail": "0.08% (환산) vs 평균 1.47%"}, {"label": "RSI", "ok": true, "detail": "51"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 13,982 (현재 13,430)"}]}
|
||||
{"ts": "2026-09-04T11:14:26.502015+09:00", "side": "SELL", "code": "030000", "name": "제일기획", "price": 18930, "qty": 281, "proceeds": 5308957, "entry_price": 19310, "pnl": -117967, "pnl_pct": -1.97, "hold_from": "2026-09-01T09:10:27.217697+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "18,249 (현재 18,940)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 21,431"}, {"label": "추세(10일선)", "ok": false, "detail": "18,942"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -2 · 기 -39"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 19,673"}]}
|
||||
{"ts": "2026-09-04T11:14:26.856117+09:00", "side": "BUY", "code": "478560", "name": "블랙야크아이앤씨", "price": 3135, "qty": 1322, "cost": 4145092, "path": "pullback", "reason": "눌림목 지정가 3,140 도달", "stop": 2812, "target": 3781, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "3,135 vs 3,004"}, {"label": "정배열(5>10)", "ok": true, "detail": "3,264 / 3,004"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "3,264 / 2,715"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+18.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +4 · 기 -12 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.24% (환산) vs 평균 0.96% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 3,495"}, {"label": "거래량 급증", "ok": false, "detail": "63,650 (환산) vs 평균 247,795"}, {"label": "회전율 급증", "ok": false, "detail": "0.24% (환산) vs 평균 0.96%"}, {"label": "RSI", "ok": true, "detail": "60"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 3,140 (현재 3,135)"}]}
|
||||
{"ts": "2026-09-04T12:34:27.150209+09:00", "side": "SELL", "code": "068270", "name": "셀트리온", "price": 187800, "qty": 53, "proceeds": 9933991, "entry_price": 194158, "pnl": -357953, "pnl_pct": -3.27, "hold_from": "2026-08-06T09:34:15.869489+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "181,367 (현재 187,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 219,742"}, {"label": "추세(10일선)", "ok": false, "detail": "189,720"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -128 · 기 -47"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-04T12:36:28.830728+09:00", "side": "BUY", "code": "079650", "name": "서산", "price": 6800, "qty": 314, "cost": 2135520, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 5443, "target": 9513, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,780 vs 5,060"}, {"label": "정배열(5>10)", "ok": true, "detail": "5,202 / 5,060"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "5,202 / 4,364"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+58.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +193 · 기 +5 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "47.40% (환산) vs 평균 23.23% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 6,760"}, {"label": "거래량 급증", "ok": true, "detail": "9,478,267 (환산) vs 평균 4,645,030"}, {"label": "회전율 급증", "ok": true, "detail": "47.40% (환산) vs 평균 23.23%"}, {"label": "RSI", "ok": true, "detail": "67"}]}
|
||||
{"ts": "2026-09-07T11:00:32.675549+09:00", "side": "BUY", "code": "478560", "name": "블랙야크아이앤씨", "price": 3255, "qty": 1273, "cost": 4144237, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 2812, "target": 3781, "signals": [{"label": "손절선", "ok": true, "detail": "2,812 (현재 3,260)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 3,781"}, {"label": "추세(10일선)", "ok": true, "detail": "3,016"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +6 · 기 -11"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 3,241"}]}
|
||||
{"ts": "2026-09-07T15:06:35.053408+09:00", "side": "SELL", "code": "327260", "name": "RF머트리얼즈", "price": 33650, "qty": 54, "proceeds": 1813557, "entry_price": 35100, "pnl": -82128, "pnl_pct": -4.13, "hold_from": "2026-08-20T10:34:23.489978+09:00", "reason": "추세·수급 이탈", "partial": false, "signals": [{"label": "손절선", "ok": true, "detail": "27,388 (현재 33,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 50,523"}, {"label": "추세(10일선)", "ok": false, "detail": "35,015"}, {"label": "수급(외/기)", "ok": false, "detail": "외 -281 · 기 -15"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 37,418"}]}
|
||||
{"ts": "2026-09-07T15:06:35.464019+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 153300, "qty": 14, "cost": 2146522, "path": "pullback", "reason": "눌림목 지정가 157,907 도달", "stop": 124661, "target": 210578, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "153,100 vs 152,890"}, {"label": "정배열(5>10)", "ok": true, "detail": "171,260 / 152,890"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "171,260 / 99,668"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+26.6%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +153 · 기 -70 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+41%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "5.10% (환산) vs 평균 3.06% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 202,500"}, {"label": "거래량 급증", "ok": false, "detail": "987,306 (환산) vs 평균 591,925"}, {"label": "회전율 급증", "ok": true, "detail": "5.10% (환산) vs 평균 3.06%"}, {"label": "RSI", "ok": true, "detail": "59"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 157,907 (현재 153,100)"}]}
|
||||
{"ts": "2026-09-08T09:18:27.729650+09:00", "side": "SELL", "code": "214450", "name": "파마리서치", "price": 370000, "qty": 22, "proceeds": 8124127, "entry_price": 409000, "pnl": -875223, "pnl_pct": -9.54, "hold_from": "2026-08-13T09:08:17.368050+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "371,344 (현재 370,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 484,313"}, {"label": "추세(10일선)", "ok": true, "detail": "308,350"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +65 · 기 -32"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-08T09:18:27.730352+09:00", "side": "BUY", "code": "004370", "name": "농심", "price": 414000, "qty": 12, "cost": 4968745, "path": "pullback", "reason": "눌림목 지정가 420,641 도달", "stop": 388711, "target": 464578, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "413,500 vs 397,100"}, {"label": "정배열(5>10)", "ok": true, "detail": "405,200 / 397,100"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "405,200 / 369,950"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+6.2%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +14 · 기 -27 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+37%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.50% (환산) vs 평균 0.51% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 440,000"}, {"label": "거래량 급증", "ok": false, "detail": "30,353 (환산) vs 평균 31,191"}, {"label": "회전율 급증", "ok": false, "detail": "0.50% (환산) vs 평균 0.51%"}, {"label": "RSI", "ok": true, "detail": "62"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 420,641 (현재 413,500)"}]}
|
||||
{"ts": "2026-09-08T09:26:31.516062+09:00", "side": "BUY", "code": "095610", "name": "테스", "price": 163200, "qty": 13, "cost": 2121918, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 124661, "target": 210578, "signals": [{"label": "손절선", "ok": true, "detail": "124,661 (현재 162,900)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 210,578"}, {"label": "추세(10일선)", "ok": true, "detail": "153,870"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +196 · 기 +54"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 162,804"}]}
|
||||
{"ts": "2026-09-08T12:32:31.611863+09:00", "side": "BUY", "code": "079650", "name": "서산", "price": 7390, "qty": 288, "cost": 2128639, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 5443, "target": 9513, "signals": [{"label": "손절선", "ok": true, "detail": "5,443 (현재 7,380)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,513"}, {"label": "추세(10일선)", "ok": true, "detail": "5,120"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +103 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 7,239"}]}
|
||||
{"ts": "2026-09-08T15:20:32.435154+09:00", "side": "SELL", "code": "138040", "name": "메리츠금융지주", "price": 130000, "qty": 89, "proceeds": 11547438, "entry_price": 119185, "pnl": 938347, "pnl_pct": 9.07, "hold_from": "2026-07-29T09:02:02.224637+09:00", "reason": "트레일링 익절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "130,576 (현재 130,300)"}, {"label": "목표/트레일링", "ok": null, "detail": "트레일링 ON"}, {"label": "추세(10일선)", "ok": true, "detail": "117,640"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -147 · 기 +423"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-09T09:02:32.237897+09:00", "side": "BUY", "code": "000720", "name": "현대건설", "price": 135800, "qty": 30, "cost": 4074611, "path": "breakout", "reason": "거래량·회전율 동반 전고점 돌파", "stop": 121854, "target": 163692, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "135,800 vs 115,200"}, {"label": "정배열(5>10)", "ok": true, "detail": "116,420 / 115,200"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "116,420 / 113,853"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+34.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 -280 · 기 +899 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+22%"}, {"label": "어닝 서프라이즈", "ok": true, "detail": "+ ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "1.32% (환산) vs 평균 0.58% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": true, "detail": "고점 122,600"}, {"label": "거래량 급증", "ok": true, "detail": "1,470,387 (환산) vs 평균 648,608"}, {"label": "회전율 급증", "ok": true, "detail": "1.32% (환산) vs 평균 0.58%"}, {"label": "RSI", "ok": true, "detail": "66"}]}
|
||||
{"ts": "2026-09-09T09:06:32.117834+09:00", "side": "BUY", "code": "000720", "name": "현대건설", "price": 141500, "qty": 28, "cost": 3962594, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 121854, "target": 163692, "signals": [{"label": "손절선", "ok": true, "detail": "121,854 (현재 141,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 163,692"}, {"label": "추세(10일선)", "ok": true, "detail": "115,740"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -280 · 기 +899"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 140,627"}]}
|
||||
{"ts": "2026-09-11T09:04:34.987337+09:00", "side": "BUY", "code": "252990", "name": "샘씨엔에스", "price": 14660, "qty": 171, "cost": 2507236, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 11243, "target": 18074, "signals": [{"label": "손절선", "ok": true, "detail": "11,243 (현재 14,510)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 18,074"}, {"label": "추세(10일선)", "ok": true, "detail": "13,024"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -103 · 기 +10"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 14,275"}]}
|
||||
{"ts": "2026-09-16T09:06:37.347501+09:00", "side": "SELL", "code": "095610", "name": "테스", "price": 128800, "qty": 27, "proceeds": 3470819, "entry_price": 158067, "pnl": -797621, "pnl_pct": -18.52, "hold_from": "2026-09-07T15:06:35.464006+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "129,556 (현재 129,200)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 215,088"}, {"label": "추세(10일선)", "ok": false, "detail": "150,500"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -853 · 기 +920"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-16T10:54:38.080667+09:00", "side": "SELL", "code": "000720", "name": "현대건설", "price": 124000, "qty": 58, "proceeds": 7177976, "entry_price": 138552, "pnl": -859230, "pnl_pct": -10.5, "hold_from": "2026-09-09T09:02:32.237894+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "124,070 (현재 124,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 167,515"}, {"label": "추세(10일선)", "ok": true, "detail": "114,020"}, {"label": "수급(외/기)", "ok": true, "detail": "외 -193 · 기 +18"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-16T13:08:39.890768+09:00", "side": "BUY", "code": "372170", "name": "윤성에프앤씨", "price": 20200, "qty": 140, "cost": 2828424, "path": "pullback", "reason": "눌림목 지정가 21,341 도달", "stop": 17261, "target": 26078, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "20,150 vs 19,073"}, {"label": "정배열(5>10)", "ok": true, "detail": "19,666 / 19,073"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "19,666 / 19,448"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+39.5%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +0 · 기 +1 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.18% (환산) vs 평균 0.56% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 23,400"}, {"label": "거래량 급증", "ok": false, "detail": "14,282 (환산) vs 평균 44,688"}, {"label": "회전율 급증", "ok": false, "detail": "0.18% (환산) vs 평균 0.56%"}, {"label": "RSI", "ok": true, "detail": "56"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 21,341 (현재 20,150)"}]}
|
||||
{"ts": "2026-09-16T13:08:40.269221+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 11710, "qty": 351, "cost": 4110827, "path": "pullback", "reason": "눌림목 지정가 11,955 도달", "stop": 10532, "target": 14065, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "11,710 vs 11,339"}, {"label": "정배열(5>10)", "ok": true, "detail": "11,772 / 11,339"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "11,772 / 9,428"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+33.0%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +14 · 기 +0 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.05% (환산) vs 평균 0.30% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 13,450"}, {"label": "거래량 급증", "ok": false, "detail": "6,274 (환산) vs 평균 36,789"}, {"label": "회전율 급증", "ok": false, "detail": "0.05% (환산) vs 평균 0.30%"}, {"label": "RSI", "ok": true, "detail": "64"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 11,955 (현재 11,710)"}]}
|
||||
{"ts": "2026-09-17T13:50:40.416232+09:00", "side": "BUY", "code": "041520", "name": "이엘씨", "price": 12170, "qty": 337, "cost": 4101905, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 10532, "target": 14065, "signals": [{"label": "손절선", "ok": true, "detail": "10,532 (현재 12,170)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 14,065"}, {"label": "추세(10일선)", "ok": true, "detail": "11,385"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +16 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 12,093"}]}
|
||||
{"ts": "2026-09-18T09:14:43.474929+09:00", "side": "SELL", "code": "004370", "name": "농심", "price": 387500, "qty": 12, "proceeds": 4640932, "entry_price": 414000, "pnl": -327813, "pnl_pct": -6.4, "hold_from": "2026-09-08T09:18:27.730350+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "388,711 (현재 388,000)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 464,578"}, {"label": "추세(10일선)", "ok": false, "detail": "394,550"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +11 · 기 -2"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 423,340"}]}
|
||||
{"ts": "2026-09-18T09:14:43.475558+09:00", "side": "BUY", "code": "025860", "name": "남해화학", "price": 6130, "qty": 848, "cost": 5199020, "path": "pullback", "reason": "눌림목 지정가 6,155 도달", "stop": 5730, "target": 6930, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "6,130 vs 6,034"}, {"label": "정배열(5>10)", "ok": true, "detail": "6,112 / 6,034"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "6,112 / 5,900"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+13.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +4 · 기 +62 (천주)"}, {"label": "애널", "ok": null, "detail": "데이터 없음(통과)"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.13% (환산) vs 평균 1.53% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 6,700"}, {"label": "거래량 급증", "ok": false, "detail": "62,267 (환산) vs 평균 758,257"}, {"label": "회전율 급증", "ok": false, "detail": "0.13% (환산) vs 평균 1.53%"}, {"label": "RSI", "ok": true, "detail": "55"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 6,155 (현재 6,130)"}]}
|
||||
{"ts": "2026-09-30T11:40:45.422955+09:00", "side": "SELL", "code": "079650", "name": "서산", "price": 5760, "qty": 602, "proceeds": 3460758, "entry_price": 7082, "pnl": -803401, "pnl_pct": -18.67, "hold_from": "2026-09-04T12:36:28.830726+09:00", "reason": "손절", "partial": false, "signals": [{"label": "손절선", "ok": false, "detail": "5,765 (현재 5,730)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 9,716"}, {"label": "추세(10일선)", "ok": true, "detail": "4,954"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +67 · 기 +0"}, {"label": "분할(2/2)", "ok": null, "detail": "추가 종료"}]}
|
||||
{"ts": "2026-09-30T11:40:45.860932+09:00", "side": "BUY", "code": "300720", "name": "한일시멘트", "price": 16110, "qty": 323, "cost": 5204311, "path": "pullback", "reason": "눌림목 지정가 16,390 도달", "stop": 15271, "target": 17789, "signals": [{"label": "추세(10일선 위)", "ok": true, "detail": "16,100 vs 15,864"}, {"label": "정배열(5>10)", "ok": true, "detail": "16,302 / 15,864"}, {"label": "추세 기울기(10일선↑)", "ok": true, "detail": "우상향"}, {"label": "중기 정배열(5>60)", "ok": true, "detail": "16,302 / 14,436"}, {"label": "상대강도(시장대비)", "ok": true, "detail": "+15.4%p"}, {"label": "수급(5일 외/기)", "ok": true, "detail": "외 +18 · 기 -4 (천주)"}, {"label": "상승여력", "ok": true, "detail": "+27%"}, {"label": "목표가 리비전", "ok": true, "detail": "상향 ➕"}, {"label": "시장 강세", "ok": true, "detail": "강세"}, {"label": "과열 아님(회전율)", "ok": true, "detail": "0.05% (환산) vs 평균 0.12% (보류 3배↑)"}, {"label": "돌파(전고점)", "ok": false, "detail": "고점 17,140"}, {"label": "거래량 급증", "ok": false, "detail": "39,139 (환산) vs 평균 89,609"}, {"label": "회전율 급증", "ok": false, "detail": "0.05% (환산) vs 평균 0.12%"}, {"label": "RSI", "ok": true, "detail": "61"}, {"label": "눌림목 도달", "ok": true, "detail": "지정가 16,390 (현재 16,100)"}]}
|
||||
{"ts": "2026-10-02T09:01:23.806528+09:00", "side": "BUY", "code": "372170", "name": "윤성에프앤씨", "price": 21400, "qty": 132, "cost": 2825224, "path": "add", "reason": "추격매수 — 2/2차 (추세 지속)", "stop": 17261, "target": 26078, "signals": [{"label": "손절선", "ok": true, "detail": "17,261 (현재 21,700)"}, {"label": "목표/트레일링", "ok": null, "detail": "목표 26,078"}, {"label": "추세(10일선)", "ok": true, "detail": "19,228"}, {"label": "수급(외/기)", "ok": true, "detail": "외 +2 · 기 +0"}, {"label": "분할(1/2)", "ok": null, "detail": "추가 트리거 21,139"}]}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"cash": 100000000,
|
||||
"initial": 100000000,
|
||||
"positions": {},
|
||||
"realized_pnl": 0.0,
|
||||
"closed_trades": 0,
|
||||
"wins": 0,
|
||||
"peak_equity": 100000000,
|
||||
"max_drawdown": 0.0,
|
||||
"last_exit": {},
|
||||
"created_at": "2026-06-11T09:00:01.944300+09:00",
|
||||
"updated_at": "2026-06-15T15:25:02.286210+09:00"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user