Compare commits
6 Commits
c4b2fffeb4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9baea3a0e2 | |||
| 80daa53558 | |||
| 35795abb0f | |||
| 4e47f5dd43 | |||
| 246c8d5328 | |||
| ed17193945 |
@@ -492,6 +492,7 @@ Gitea Webhook 수신 → 자동 배포. HMAC SHA256 검증(`X-Gitea-Signature`
|
||||
- **공휴일 목록**: `stock/app/holidays.json` 매년 수동 갱신 (KRX 기준)
|
||||
- **Windows AI 서버 IP**: `192.168.45.59` (DHCP 고정 예약). Tailscale은 Synology userspace 모드라 TCP 불가 → 로컬 IP 사용
|
||||
- **렌더/생성 워커 분리**: music/video/image/insta 무거운 작업은 Windows `web-ai` 워커. NAS 코드의 `*_provider.py`/`card_renderer.py`가 DEPRECATED stub면 실 로직은 web-ai 쪽이 authoritative
|
||||
- **[팀 규칙] 모든 WSL(docker) 워커는 `/infra`에서 관측 가능해야 한다**: 새 워커 추가 시 필수 3단계 — ① 워커가 `worker:<name>:heartbeat`(EX45, ~15초) 발신 ② BE가 `agent-office/app/node_monitor.py`의 `WORKER_REGISTRY`에 `{name,kind,queue}` 등재 ③ → `/api/agent-office/nodes`·web-ui `/infra` 노출 + 다운/복구/dead-letter 텔레그램 경보. 미준수 = "사일런트 사망"(insta-render 2주 무관측 사고) 재발 위험. 워커 신규/변경 PR 머지 게이트. web-ai/web-ui repo CLAUDE.md에도 동일 규칙 명시 필요. 상세는 `infra_distributed_workers.md` 메모리(관측 계약 2)
|
||||
- **Playwright Dockerfile**: bookworm 고정 + 수동 chromium deps, `--with-deps` 금지 (`feedback_playwright_dockerfile.md`)
|
||||
- **lab 네이밍**: `-lab`은 개발/연구 단계에만, 정식 서비스엔 미사용 (`feedback_lab_naming.md`)
|
||||
|
||||
|
||||
54
README.md
54
README.md
@@ -115,6 +115,7 @@ curl http://localhost:18500/health
|
||||
- **실계좌**: Windows AI 서버(192.168.45.59:8000) 프록시 → KIS Open API (잔고/주문)
|
||||
- **포트폴리오**: 종목·예수금·매도 히스토리 관리, 현재가 자동 조회
|
||||
- **자산 스냅샷**: 평일 15:40 자동 저장 (KRX 공휴일 판별, `holidays.json` 매년 갱신)
|
||||
- **실시간 매매 알람** (2026-07-02): 장중(+시간외) 1분 폴링으로 매수(watchlist ∪ 스크리너 후보, TA 시그널)·매도(보유종목, exit 룰 + 트레일링 스톱) 조건 충족 시 텔레그램(본인+아내) 알람. **TA 계산은 Windows `trade-monitor` WSL2 docker 워커**, NAS는 감시대상 조립 + edge 중복판정(영속) + 발송 담당. 관심종목은 `/api/stock/watchlist` CRUD 또는 텔레그램 `/watch` 봇 명령. webai 계약: `GET /api/webai/trade-alert/monitor-set` · `POST /report`. 워커/프론트 탭은 web-ai/web-ui repo (설계: `docs/superpowers/specs/2026-07-02-realtime-trade-alerts-design.md`)
|
||||
|
||||
**LLM provider 전환** — `LLM_PROVIDER` 환경변수
|
||||
- `claude` (기본): Anthropic Messages API (`claude-haiku-4-5`)
|
||||
@@ -169,6 +170,8 @@ AI 에이전트 가상 오피스 — 2D 픽셀아트 사무실에서 4명의 에
|
||||
- **텔레그램 연동**: 양방향 알림 + 인라인 키보드 승인
|
||||
- 봇이 작업 결과를 텔레그램으로 푸시, 명령은 텔레그램에서 바로 에이전트에 전달
|
||||
- Webhook 검증 후 `chat.id` 기준 라우팅
|
||||
- **실시간 매매 알람 수신**: `POST /api/agent-office/stock/trade-alert` (stock이 edge 판정한 알람 push) → 텔레그램 본인+아내 발송. 봇 명령 `/watch`·`/unwatch`·`/watchlist`로 관심종목 관리
|
||||
- **분산 워커 관측**: `GET /api/agent-office/nodes`가 `worker:<name>:heartbeat`를 집계 → web-ui `/infra` 시각화 + 다운/복구/dead-letter 텔레그램 경보. WSL docker 워커는 `node_monitor.WORKER_REGISTRY` 등재 필수(위 주의사항 팀 규칙)
|
||||
|
||||
#### 에이전트 구성
|
||||
|
||||
@@ -283,11 +286,11 @@ git push → Gitea → X-Gitea-Signature (HMAC SHA256)
|
||||
| DB | 소유 서비스 | 주요 테이블 |
|
||||
|----|------------|-----------|
|
||||
| `lotto.db` | lotto | draws, recommendations, simulation_runs/candidates, best_picks, purchase_history, strategy_performance/weights, weekly_reports, lotto_briefings |
|
||||
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history |
|
||||
| `stock.db` | stock | articles, portfolio, broker_cash, asset_snapshots, sell_history, holdings_signals, news_sentiment, **watchlist, trade_alert_state, trade_alert_history** (실시간 매매 알람) |
|
||||
| `music.db` | music-lab | music_tasks, music_library (provider, lyrics, image_url, suno_id, file_hash, cover_images, wav_url, video_url, stem_urls), video_projects, revenue_records, market_trends, trend_reports |
|
||||
| `insta.db` | insta-lab | news_articles, trending_keywords (source 컬럼), card_slates, card_assets, generation_tasks, prompt_templates, account_preferences |
|
||||
| `realestate.db` | realestate-lab | announcements, announcement_models, user_profile, match_results, collect_log |
|
||||
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages |
|
||||
| `agent_office.db` | agent-office | agent_config, agent_tasks, agent_logs, telegram_state, conversation_messages, youtube_research_jobs, lotto_signals/baselines, notified_failed_pipelines (파이프라인 실패 알림 dedup) |
|
||||
| `personal.db` | personal | profile, careers, projects, skills, introductions, todos, blog_posts |
|
||||
| `travel.db` | travel-proxy | photos (album, filename, mtime, has_thumb), album_covers |
|
||||
| `pack_files` (외부 Supabase) | packs-lab | filename, host_path, mime, byte_size, sha256, deleted_at |
|
||||
@@ -384,6 +387,52 @@ PORTFOLIO_EDIT_PASSWORD=
|
||||
- **Suno CDN** — `cdn1.suno.ai` URL은 임시 만료 → 생성 즉시 로컬 다운로드 필수
|
||||
- **LLM provider 롤백** — Claude API 장애 시 `.env`의 `LLM_PROVIDER=ollama`로 전환 후 `docker compose up -d`
|
||||
- **시뮬레이션 교체 방식** — `best_picks`는 교체형 (`is_active=0` 비활성화 후 신규 입력)
|
||||
- **[팀 규칙] 모든 WSL docker 워커는 `/infra` 관측 필수** — 새 워커는 ① `worker:<name>:heartbeat`(EX45) 발신 ② BE가 `agent-office/app/node_monitor.py`의 `WORKER_REGISTRY`에 등재 ③ → `/api/agent-office/nodes`·web-ui `/infra` 노출 + 다운/복구/dead-letter 경보. 미준수 = 사일런트 사망 재발(insta-render 2주 사고). 워커 PR 머지 게이트
|
||||
- **Alpine + tzdata 함정** — stock 컨테이너는 `python:3.12-alpine` + tzdata 미설치라 `TZ=Asia/Seoul`이 무효 → `date.today()`가 UTC. KST 날짜는 `_today_kst()`(=`utcnow()+9h`) 명시 변환 필수 (아침 스케줄 리포트 하루 밀림 방지)
|
||||
|
||||
---
|
||||
|
||||
## 하네스 엔지니어링 (Claude Code 제어)
|
||||
|
||||
이 레포는 Claude Code 세션의 동작을 `.claude/` 설정으로 **제어(harness engineering)** 한다. 모든 산출물은 git 추적되어 이 체크아웃의 모든 세션(co-gahusb 팀버스의 BE 역할 포함)에 공유된다.
|
||||
|
||||
### 제어 표면 (무엇을 통제하는가)
|
||||
|
||||
| 레이어 | 메커니즘 | 위치 | 역할 |
|
||||
|--------|---------|------|------|
|
||||
| 컨텍스트 주입 | CLAUDE.md 계층 + 서비스 메모리 | `CLAUDE.md`, `memory/service_*.md` | 항상 로딩되는 카탈로그(불변) ↔ 관련 시 recall(가변) 2계층 |
|
||||
| 권한 가드 | permissions allow/deny/ask | `.claude/settings.json` | 읽기전용 명령 무프롬프트 / 시크릿·DB 차단 / push·reset 확인 |
|
||||
| 행동 강제 | PreToolUse·PostToolUse·SessionStart hook | `.claude/hooks/` | CLAUDE.md 주석 규칙을 하네스가 실제 차단·환기 |
|
||||
| 반복 워크플로우 | slash commands | `.claude/commands/` | `/co-inbox`, `/svc`, `/harness-audit` |
|
||||
| 전문 역할 | subagents | `.claude/agents/` | `be-developer`, `evaluator` |
|
||||
| 협업 버스 | MCP 서버 | `.mcp.json` | co-gahusb 팀버스(세션 간 메시지·작업·락) |
|
||||
|
||||
### 적용된 가드 (hook)
|
||||
|
||||
| hook | 이벤트 / matcher | 동작 | 근거 |
|
||||
|------|-----------------|------|------|
|
||||
| `pretooluse-guard.sh` | PreToolUse · `Bash\|PowerShell` | **차단** 로컬 docker 변경(`up/down/build/restart/exec…`; ps·logs·config·images는 허용) | `feedback_docker_nas` |
|
||||
| 〃 | 〃 | **차단** `git commit --amend` · `git push --force`(`--force-with-lease`는 허용) | `feedback_concurrent_session_git_collision` |
|
||||
| 〃 | 〃 | **차단** PowerShell `>`/`>>` 파일 리다이렉트(UTF-16 BOM; `2>$null`·`> $null`은 허용) | `feedback_powershell_redirect_encoding` |
|
||||
| `posttooluse-memory.sh` | PostToolUse · `Edit\|Write` | 서비스 `db.py`/`models.py`/스케줄러/`.sql` 편집 시 `service_<name>.md` 갱신 환기(비차단) | 메모리 디스플린 |
|
||||
| `session-start.sh` | SessionStart · `startup\|resume` | BE 역할 + 수신함/락 넛지 주입 | 협업 버스 프로토콜 |
|
||||
|
||||
차단 판단 로직은 `.claude/hooks/_guard.py`(Python). 래퍼는 파서 부재 시 **fail-open**(통과)하고, 출력은 UTF-8로 고정한다.
|
||||
|
||||
### slash commands
|
||||
|
||||
| 커맨드 | 용도 |
|
||||
|--------|------|
|
||||
| `/co-inbox` | co-gahusb 팀버스 BE 수신함(inbox + tasks + locks) 일괄 확인 |
|
||||
| `/svc <name>` | 해당 `service_<name>.md` 메모리 + 핵심 파일 위치를 즉시 로드 |
|
||||
| `/harness-audit` | 서브에이전트 fan-out으로 CLAUDE.md 카탈로그 ↔ 실제 코드 드리프트 감사 |
|
||||
|
||||
### 확장 / 유지보수
|
||||
|
||||
- **hook 경로는 이 머신 기준 절대경로**(`/c/Users/jaeoh/Desktop/workspace/web-backend/.claude/hooks/…`)다. 레포를 다른 경로로 클론하면 `settings.json`의 3개 hook command 경로를 갱신해야 한다.
|
||||
- 가드 패턴 추가/수정은 `_guard.py`만 고치면 된다(설정 변경 불필요).
|
||||
- hook은 새 세션에서 자동 로드된다. 진행 중 세션에 즉시 반영하려면 `/hooks` 메뉴를 열거나 재시작한다.
|
||||
- 메모리 디스플린: 코드 구조가 바뀌면 **CLAUDE.md(불변 카탈로그)** 가 아니라 **`service_*.md`(가변 상세)** 를 갱신한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -391,3 +440,4 @@ PORTFOLIO_EDIT_PASSWORD=
|
||||
|
||||
- `CLAUDE.md` — Claude Code 작업용 상세 컨텍스트 (API 전체 목록, 테이블 스키마 등)
|
||||
- `docs/` — 서비스별 기획·설계 문서
|
||||
- `.claude/` — 하네스 설정(settings·hooks·commands·agents). 위 "하네스 엔지니어링" 섹션 참조
|
||||
|
||||
@@ -14,8 +14,9 @@ WORKER_REGISTRY = [
|
||||
{"name": "video-render", "kind": "render", "queue": "queue:video-render"},
|
||||
{"name": "image-render", "kind": "render", "queue": "queue:image-render"},
|
||||
{"name": "insta-render", "kind": "render", "queue": "queue:insta-render"},
|
||||
{"name": "task-watcher", "kind": "watcher", "queue": None},
|
||||
{"name": "ai_trade", "kind": "trader", "queue": None},
|
||||
{"name": "task-watcher", "kind": "watcher", "queue": None},
|
||||
{"name": "ai_trade", "kind": "trader", "queue": None},
|
||||
{"name": "trade-monitor", "kind": "trader", "queue": None},
|
||||
]
|
||||
|
||||
_redis = None
|
||||
@@ -92,7 +93,7 @@ async def collect_status(redis=None) -> dict:
|
||||
|
||||
for w in out["workers"]:
|
||||
if w["kind"] == "trader":
|
||||
out["links"].append({"from": "ai_trade", "to": "nas-stock", "type": "http-pull",
|
||||
out["links"].append({"from": w["name"], "to": "nas-stock", "type": "http-pull",
|
||||
"status": "healthy" if w["alive"] else "down"})
|
||||
elif w["kind"] == "render":
|
||||
out["links"].append({"from": "nas", "to": w["name"], "type": "redis-queue",
|
||||
|
||||
@@ -13,15 +13,31 @@ _COND_LABEL = {
|
||||
"sell_stop_loss": "손절", "sell_ma_break": "이평 이탈", "sell_take_profit": "익절",
|
||||
"sell_climax": "급등 소진", "sell_trailing_stop": "트레일링 스톱",
|
||||
}
|
||||
# 조건별 "왜 이 시점에 매수/매도인가" 한 줄 근거
|
||||
_COND_REASON = {
|
||||
"buy_ma20_pullback": "상승추세 중 MA20 지지선 눌림목 반등 — 저가 진입 기회",
|
||||
"buy_breakout": "전고점·저항 돌파 + 거래량 증가 — 추세 상승 진입 신호",
|
||||
"buy_rsi_bounce": "RSI 과매도(30↓)에서 반등 — 단기 낙폭과대 되돌림",
|
||||
"sell_stop_loss": "평단 대비 손절선 도달 — 추가 하락 리스크 차단",
|
||||
"sell_ma_break": "주요 이평선(MA50/200) 이탈 — 추세 훼손, 보유 재검토",
|
||||
"sell_take_profit": "목표 수익 도달 — 이익 실현 구간",
|
||||
"sell_climax": "거래량 급증 + 윗꼬리(고점 대비 하락 마감) — 분산·소진 의심",
|
||||
"sell_trailing_stop":"보유기간 고점 대비 하락 — 수익 반납 방어(트레일링 스톱)",
|
||||
}
|
||||
|
||||
|
||||
def format_trade_alert(a: Dict[str, Any]) -> str:
|
||||
kind = _KIND_LABEL.get(a["kind"], a["kind"])
|
||||
cond = _COND_LABEL.get(a["condition"], a["condition"])
|
||||
reason = _COND_REASON.get(a["condition"], "")
|
||||
name = a.get("name") or a["ticker"]
|
||||
price = a.get("price")
|
||||
price_s = f"{int(price):,}원" if price else "-"
|
||||
return f"{kind} 알람\n<b>{name}</b> ({a['ticker']})\n조건: {cond}\n현재가: {price_s}"
|
||||
lines = [f"{kind} 알람", f"<b>{name}</b> ({a['ticker']})", f"조건: {cond}"]
|
||||
if reason:
|
||||
lines.append(f"💡 {reason}")
|
||||
lines.append(f"현재가: {price_s}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def send_trade_alerts(alerts: List[Dict[str, Any]]) -> dict:
|
||||
|
||||
@@ -68,6 +68,18 @@ async def test_trader_http_pull_link():
|
||||
link = next(l for l in st["links"] if l["from"] == "ai_trade")
|
||||
assert link["type"] == "http-pull" and link["status"] == "healthy"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trade_monitor_registered_and_own_link():
|
||||
"""WSL 워커 trade-monitor가 registry에 있어 /nodes에 노출되고, 링크 from은
|
||||
ai_trade 하드코딩이 아니라 자기 이름(trade-monitor)이어야 한다 (다중 trader 구분)."""
|
||||
r = FakeRedis(kv={"worker:trade-monitor:heartbeat": _hb("trade-monitor", "trader", "market_open")})
|
||||
st = await node_monitor.collect_status(redis=r)
|
||||
tm = next(w for w in st["workers"] if w["name"] == "trade-monitor")
|
||||
assert tm["alive"] is True and tm["kind"] == "trader"
|
||||
link = next(l for l in st["links"] if l["from"] == "trade-monitor")
|
||||
assert link["type"] == "http-pull" and link["to"] == "nas-stock" and link["status"] == "healthy"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_paused_no_watcher_heartbeat_fallback_reason():
|
||||
"""paused=True인데 watcher heartbeat 없으면 paused_reason == 'trading' 폴백."""
|
||||
|
||||
@@ -53,3 +53,15 @@ async def test_format_trade_alert_has_direction():
|
||||
txt = format_trade_alert({"ticker": "005930", "name": "삼성전자", "kind": "sell",
|
||||
"condition": "sell_stop_loss", "price": 60000, "detail": {}})
|
||||
assert "매도" in txt and "삼성전자" in txt
|
||||
|
||||
|
||||
def test_format_trade_alert_includes_reason_line():
|
||||
"""조건별 '왜 매수/매도해야 하는지' 한 줄 이유(💡)가 메시지에 포함된다."""
|
||||
from app.notifiers.telegram_trade import format_trade_alert
|
||||
for cond in ("buy_breakout", "sell_stop_loss", "sell_trailing_stop"):
|
||||
txt = format_trade_alert({"ticker": "005930", "name": "삼성전자", "kind": cond.split("_")[0],
|
||||
"condition": cond, "price": 60000, "detail": {}})
|
||||
assert "💡" in txt, f"{cond}: 이유 한 줄 누락"
|
||||
# 이유 라인이 조건 라벨을 그대로 반복하지 않고 실제 설명을 담아야 함
|
||||
reason_line = next(l for l in txt.split("\n") if l.startswith("💡"))
|
||||
assert len(reason_line) > 6
|
||||
|
||||
@@ -465,10 +465,17 @@ def get_alert_state_firing() -> set:
|
||||
return {(r["ticker"], r["kind"], r["condition"]) for r in rows}
|
||||
|
||||
|
||||
def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, at_iso: str = None) -> None:
|
||||
def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool,
|
||||
at_iso: str = None, mark_fired: bool = True) -> None:
|
||||
"""currently_firing 상태 갱신.
|
||||
|
||||
mark_fired=True(기본): 실제 알림 발송 → first/last_fired_at 갱신.
|
||||
mark_fired=False: 쿨다운으로 발송 억제하되 firing 상태만 유지 → 발동시각 미갱신
|
||||
(쿨다운이 계속 연장되지 않도록).
|
||||
"""
|
||||
now = at_iso or _now_iso()
|
||||
with _conn() as conn:
|
||||
if firing:
|
||||
if firing and mark_fired:
|
||||
conn.execute(
|
||||
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,first_fired_at,last_fired_at,last_seen_at)
|
||||
VALUES(?,?,?,1,?,?,?)
|
||||
@@ -479,6 +486,14 @@ def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, at_is
|
||||
last_seen_at=excluded.last_seen_at""",
|
||||
(ticker, kind, condition, now, now, now),
|
||||
)
|
||||
elif firing and not mark_fired:
|
||||
conn.execute(
|
||||
"""INSERT INTO trade_alert_state(ticker,kind,condition,currently_firing,last_seen_at)
|
||||
VALUES(?,?,?,1,?)
|
||||
ON CONFLICT(ticker,kind,condition) DO UPDATE SET
|
||||
currently_firing=1, last_seen_at=excluded.last_seen_at""",
|
||||
(ticker, kind, condition, now),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"UPDATE trade_alert_state SET currently_firing=0, last_seen_at=? WHERE ticker=? AND kind=? AND condition=?",
|
||||
@@ -486,6 +501,32 @@ def set_alert_firing(ticker: str, kind: str, condition: str, firing: bool, at_is
|
||||
)
|
||||
|
||||
|
||||
def get_alert_last_fired_map() -> dict:
|
||||
"""{(ticker,kind,condition): last_fired_at ISO} — 쿨다운 판정용."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT ticker,kind,condition,last_fired_at FROM trade_alert_state"
|
||||
).fetchall()
|
||||
return {(r["ticker"], r["kind"], r["condition"]): r["last_fired_at"] for r in rows}
|
||||
|
||||
|
||||
def get_ticker_name(ticker: str) -> Optional[str]:
|
||||
"""종목명 해석 — watchlist → portfolio → krx_master 순. 없으면 None."""
|
||||
with _conn() as conn:
|
||||
for sql in (
|
||||
"SELECT name FROM watchlist WHERE ticker=?",
|
||||
"SELECT name FROM portfolio WHERE ticker=? LIMIT 1",
|
||||
"SELECT name FROM krx_master WHERE ticker=?",
|
||||
):
|
||||
try:
|
||||
row = conn.execute(sql, (ticker,)).fetchone()
|
||||
except sqlite3.OperationalError:
|
||||
continue # 일부 테스트 DB엔 해당 테이블 부재
|
||||
if row and row["name"]:
|
||||
return row["name"]
|
||||
return None
|
||||
|
||||
|
||||
def touch_alert_seen(keys: list, at_iso: str) -> None:
|
||||
with _conn() as conn:
|
||||
for (ticker, kind, condition) in keys:
|
||||
|
||||
@@ -23,6 +23,7 @@ from .db import (
|
||||
add_sell_history, get_sell_history, update_sell_history, delete_sell_history,
|
||||
add_watchlist, remove_watchlist, get_watchlist, get_alert_history,
|
||||
get_alert_state_firing, set_alert_firing, touch_alert_seen, add_alert_history,
|
||||
get_alert_last_fired_map, get_ticker_name,
|
||||
)
|
||||
from .scraper import fetch_market_news, fetch_major_indices
|
||||
from .price_fetcher import get_current_prices, get_current_prices_detail
|
||||
@@ -548,15 +549,30 @@ def post_trade_alert_report(req: TradeAlertReport):
|
||||
전송 실패 시 상태를 채택하지 않아 다음 사이클에 동일 alert가 다시
|
||||
"신규"로 잡혀 재시도된다(멱등). 해제(cleared)는 전송과 무관하게 firing=False.
|
||||
"""
|
||||
from datetime import datetime, timedelta
|
||||
cooldown_h = float(os.getenv("TRADE_ALERT_COOLDOWN_HOURS", "6"))
|
||||
now = datetime.utcnow()
|
||||
|
||||
prev = get_alert_state_firing()
|
||||
last_fired = get_alert_last_fired_map()
|
||||
d = diff_firing(req.firing, prev)
|
||||
|
||||
new_count = 0
|
||||
suppressed = 0
|
||||
for a in d["new"]:
|
||||
if trade_alerts.notify_agent_office([a]):
|
||||
set_alert_firing(a["ticker"], a["kind"], a["condition"], firing=True, at_iso=req.as_of)
|
||||
key = (a["ticker"], a["kind"], a["condition"])
|
||||
# 쿨다운: 같은 종목·조건이 최근 발동됐으면(해제→재발화 오실레이션) 재알림 억제
|
||||
lf = last_fired.get(key)
|
||||
if cooldown_h > 0 and _within_cooldown(now, lf, timedelta(hours=cooldown_h)):
|
||||
set_alert_firing(*key, firing=True, mark_fired=False) # firing 유지, 발동시각 미갱신
|
||||
suppressed += 1
|
||||
continue
|
||||
name = a.get("name") or get_ticker_name(a["ticker"])
|
||||
alert = {**a, "name": name}
|
||||
if trade_alerts.notify_agent_office([alert]):
|
||||
set_alert_firing(*key, firing=True) # 발동시각 갱신(UTC)
|
||||
add_alert_history(
|
||||
a["ticker"], a.get("name"), a["kind"], a["condition"],
|
||||
a["ticker"], name, a["kind"], a["condition"],
|
||||
a.get("price"), a.get("detail") or {},
|
||||
)
|
||||
new_count += 1
|
||||
@@ -566,7 +582,19 @@ def post_trade_alert_report(req: TradeAlertReport):
|
||||
|
||||
touch_alert_seen(d["seen"], req.as_of or "")
|
||||
|
||||
return {"new_alerts": new_count, "cleared": len(d["cleared"])}
|
||||
return {"new_alerts": new_count, "cleared": len(d["cleared"]), "suppressed": suppressed}
|
||||
|
||||
|
||||
def _within_cooldown(now, last_iso, cooldown) -> bool:
|
||||
"""last_iso(UTC ISO `%Y-%m-%dT%H:%M:%fZ`)가 now 기준 cooldown 이내면 True."""
|
||||
if not last_iso:
|
||||
return False
|
||||
from datetime import datetime
|
||||
try:
|
||||
lf = datetime.strptime(last_iso, "%Y-%m-%dT%H:%M:%fZ")
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return (now - lf) < cooldown
|
||||
|
||||
|
||||
@app.post("/api/portfolio", status_code=201)
|
||||
|
||||
@@ -32,7 +32,8 @@ def current_session(now_kst) -> str:
|
||||
return "closed"
|
||||
|
||||
|
||||
DEFAULT_EXIT_PARAMS = {"stop_pct": 0.08, "take_pct": 0.25, "trailing_pct": 0.10}
|
||||
DEFAULT_EXIT_PARAMS = {"stop_pct": 0.08, "take_pct": 0.25, "trailing_pct": 0.10,
|
||||
"climax_vol_x": 3.0, "climax_close_pct": 0.97}
|
||||
DEFAULT_BUY_PARAMS = {"rsi_oversold": 30, "breakout_vol_mult": 1.5, "pullback_pct": 0.02}
|
||||
|
||||
|
||||
|
||||
@@ -33,3 +33,11 @@ def test_monitor_set_ok(client):
|
||||
assert body["session"] in ("pre", "regular", "after", "closed")
|
||||
assert "buy_targets" in body and "sell_targets" in body
|
||||
assert body["exit_params"]["trailing_pct"] == 0.10
|
||||
|
||||
|
||||
def test_monitor_set_exit_params_include_climax(client):
|
||||
"""climax 파라미터 중앙화 — 워커가 하드코딩 대신 NAS exit_params에서 받아 튜닝."""
|
||||
ep = client.get("/api/webai/trade-alert/monitor-set",
|
||||
headers={"X-WebAI-Key": "k"}).json()["exit_params"]
|
||||
assert ep["climax_vol_x"] == 3.0
|
||||
assert ep["climax_close_pct"] == 0.97
|
||||
|
||||
@@ -46,11 +46,40 @@ def test_report_send_failure_does_not_persist(client):
|
||||
assert r2.json()["new_alerts"] == 1
|
||||
|
||||
|
||||
def test_report_cleared_rearm(client):
|
||||
def test_report_cooldown_suppresses_immediate_refire(client):
|
||||
"""같은 종목·조건이 해제됐다 곧바로 재발화해도 쿨다운(기본 6h) 내면 재알림 억제."""
|
||||
firing = [{"ticker": "005930", "name": "삼성", "kind": "buy",
|
||||
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
||||
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||
assert _report(client, firing).json()["new_alerts"] == 1 # 최초 알림
|
||||
_report(client, []) # 해제
|
||||
r = _report(client, firing) # 즉시 재발화 → 쿨다운 억제
|
||||
assert r.json()["new_alerts"] == 0
|
||||
assert r.json()["suppressed"] == 1
|
||||
|
||||
|
||||
def test_report_refire_after_cooldown_alerts(client, monkeypatch):
|
||||
"""쿨다운=0이면 해제 후 재발화 시 재알림."""
|
||||
monkeypatch.setenv("TRADE_ALERT_COOLDOWN_HOURS", "0")
|
||||
firing = [{"ticker": "005930", "name": "삼성", "kind": "buy",
|
||||
"condition": "buy_breakout", "price": 71500, "detail": {}}]
|
||||
with patch("app.trade_alerts.notify_agent_office", return_value=True):
|
||||
_report(client, firing)
|
||||
_report(client, []) # 해제
|
||||
r = _report(client, firing) # 재발화
|
||||
_report(client, [])
|
||||
r = _report(client, firing)
|
||||
assert r.json()["new_alerts"] == 1
|
||||
|
||||
|
||||
def test_report_resolves_stock_name_from_watchlist(client):
|
||||
"""워커 firing에 name이 없어도 NAS가 종목명을 해석해 알림에 포함한다."""
|
||||
from app import db
|
||||
db.add_watchlist("000660", "SK하이닉스")
|
||||
firing = [{"ticker": "000660", "kind": "buy", "condition": "buy_breakout",
|
||||
"price": 180000, "detail": {}}] # name 없음
|
||||
with patch("app.trade_alerts.notify_agent_office", return_value=True) as m:
|
||||
_report(client, firing)
|
||||
sent_alert = m.call_args[0][0][0]
|
||||
assert sent_alert["name"] == "SK하이닉스"
|
||||
# 이력에도 종목명 기록
|
||||
alerts = client.get("/api/stock/trade-alerts?days=1").json()["alerts"]
|
||||
assert alerts[0]["name"] == "SK하이닉스"
|
||||
|
||||
Reference in New Issue
Block a user