57 Commits

Author SHA1 Message Date
94569a4c45 Enhance tarot reading experience 2026-05-24 12:39:20 +09:00
6d73a075f7 feat(tarot): 랜딩 상단 nav + account 제거, ARCANA TAROT brand만 유지
topbar wrapper 제거, brand가 hero-content 직속 첫 자식이 됨.
nav(오늘의 카드/타로 리딩/스프레드/가이드/마이 페이지) + account(프리미엄/로그인)
모두 제거 — brand 단독으로 좌상단 표시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 02:04:12 +09:00
840cc28043 feat(tarot): 반응형 풀-width 레이아웃 + clamp 기반 fluid sizing
랜딩:
- topbar로 brand + nav 같은 줄에 묶음 (시안 부합)
- hero content max-width 1200→1600px, padding clamp(24px,4vw,80px)
- h1 size clamp(40px,6vw,84px), margin clamp(40px,6vw,80px)
- sub max-width 520px→44ch + line-height
- tier-row repeat(auto-fit, minmax(240px,1fr)) — 큰 화면 자동 펼침

Reading:
- max-width 1280→1800px, padding clamp(20px,3vw,60px)
- grid columns clamp 기반 fluid (좌 22vw, 우 26vw)
- mid breakpoint 1280px에서 비율 보정, 1024px 이하 single column

History: max-width 960→1400px

Card grid: repeat(auto-fit) — 화면 폭 활용
640px 이하 step indicator wrap + cta wrap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:40:13 +09:00
423304dce3 feat(tarot): 시안 기반 UI 재구성 — 랜딩 좌→우 그라데이션 + Reading 테이블 배경
랜딩(tarot_main_landing_page.png 참고):
- hero overlay를 full-screen dark에서 좌→우 그라데이션으로 변경
- 좌측만 어둡게 (텍스트 가독), 우측은 영상 선명히 노출

Reading(tarot_card_select_page.png 참고):
- tarot_table.png 배경 fixed (보라 신비 톤 + vignette)
- 상단 step indicator (질문 & 설정 → 카드 선택 → 해석)
- 패널 backdrop-filter blur + 금색 보더로 시안 느낌 강화
- 하단 남은 카드 row 미리보기 (12장)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:12:04 +09:00
024e340e0c fix(tarot): 히어로 영상이 정적 poster img에 가려지는 z-index 충돌 해결
video와 poster img가 같은 z-index:0 + position:absolute였고 DOM 순서상
poster가 늦게 와서 video를 영원히 덮음 → 영상 재생 중이지만 안 보임.

z-index 계층 명시: poster=0 (fallback) → video=1 → overlay=2 → content=3.
video display:none 처리되면 뒤의 poster img가 자동 노출되도록 stacking 정리.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 01:02:28 +09:00
b46f4aed80 chore(tarot): 히어로 영상 압축 (9.4MB → 4.47MB)
5MB threshold 이하로 압축. 첫 paint 데이터 부담 절감.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:55:19 +09:00
09e2b67039 feat(tarot): 카드 78장 + 카드 뒷면 PNG 자산 통합
라이더-웨이트 메이저 22 + 마이너 56 + 카드 뒷면.
slug 매핑 (the-fool, ace-of-wands 등)으로 자동 표시.
TarotCard 뒷면 참조를 SVG → PNG로 전환.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:54:15 +09:00
f3551815d1 feat(tarot): 라우팅 4 페이지 + navLinks 추가 (T17)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:49:32 +09:00
bc6c45dee3 feat(tarot): History.jsx — 마이페이지 (T16)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:48:01 +09:00
d08b20a4b5 feat(tarot): Reading.jsx — 3장 스프레드 메인 (T15)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:46:29 +09:00
44bbff297f feat(tarot): TodayCard.jsx — 원카드 페이지 (T14)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:45:01 +09:00
1387d91ac5 feat(tarot): 랜딩 페이지 Tarot.jsx (T13)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:43:48 +09:00
ce84e277a4 feat(tarot): Tarot.css 디자인 토큰 + 4 페이지 스타일 (T12)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:42:35 +09:00
4c82fa9b21 feat(tarot): TarotCard·CardGrid·SpreadSlots·InterpretationPanel 컴포넌트 (T11)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:39:22 +09:00
d91be529eb feat(tarot): useTarotReading hook + api helper 6종 (T10)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:36:08 +09:00
1a7dfe73e4 feat(tarot): useTarotShuffle hook (Fisher-Yates + reversed 플래그) (T9)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:33:03 +09:00
cdf8759aef feat(tarot): 카드 78장 메타데이터 (메이저 22 + 마이너 56) (T8)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:30:41 +09:00
2042457000 feat(tarot): 히어로 영상 + 배경 + 카드 뒷면 SVG (T7)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 00:25:54 +09:00
c998753eea feat(insta): 카드 탭 트렌딩 키워드 중복 제거 + 10개씩 페이지네이션
KeywordsPanel이 전체 목록을 세로로 길게 표시하던 것을, 동일 keyword
중복 제거(최고 score 유지)·score 내림차순 후 페이지당 10개만 렌더하고
이전(←)/다음(→) 페이저로 탐색하도록 변경. 카테고리 변경 시 첫 페이지 리셋.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 03:03:36 +09:00
a846ab89e6 feat(lotto): 헤더 카드를 자율 학습 시스템으로 업데이트
Why: v1(능동 시그널) + v2(자율 가중치 학습) + v2.1(활동 가시화)로
시스템이 진화한 것을 반영. 기존 '시뮬레이션 추천 시스템' 3 bullet
→ '자율 학습 시뮬레이션' 4 bullet (학습 루프·시그널·시뮬·AI 큐레이터).
2026-05-23 02:43:47 +09:00
ef392f02ed refactor(evolver): Lotto 탭으로 통합 + 다크 테마 + activity 스크롤
- EvolverTab.jsx 신규 생성: evolver 컴포넌트를 탭 body로 추출
- Evolver.jsx → Lotto 페이지 thin wrapper로 교체 (/lotto/evolver URL 유지)
- Lotto.jsx: useLocation으로 pathname 감지 → initialTab 결정
- Functions.jsx: 4번째 탭 '🧬 자율 학습' 추가 + initialTab prop 수용
- Evolver.css: light → dark 테마 전환 (rgba/slate 팔레트), activity-list max-height+scroll 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 02:38:33 +09:00
2543dc335d feat(evolver): Evolver 페이지 + LottoActivityTimeline + EvolverActions + 라우터
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 02:19:07 +09:00
b99d720179 feat(evolver): TrialsGrid + BaseDiff + BaseHistory 3 컴포넌트
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 02:16:15 +09:00
734bc6532e feat(evolver): WinnerCard — Radar + 이전 base overlay + 메타 정보 2026-05-23 02:14:58 +09:00
5fd32030ab feat(evolver): useEvolverApi hook (4 fetch + activity merge sort) 2026-05-23 02:14:16 +09:00
e8d33906ba feat(evolver): api.js에 evolver + lotto activity fetch helpers (6개) 2026-05-23 02:13:35 +09:00
6533743100 fix(stock): 총 매입을 각 종목 매입가의 단순 합으로 표시
요약카드(백엔드 매입가×수량)와 증권사별(매입가 단순 합) 총 매입이 서로
달라 혼란. 박재오 정의대로 총 매입 = Σ매입가(수량 미곱산)로 통일.
getBrokerSummary를 stockUtils.computeBrokerSummary로 추출(테스트 5건),
usePortfolio가 portfolioSummary.total_buy를 프론트 단순 합으로 override해
요약카드·증권사별·AI 프롬프트가 동일 값 사용. 손익은 avg_price×수량 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 11:15:58 +09:00
e42b643731 refactor(stock): 거래 데스크에서 AI 투자 탭 제거
TAB_AI 탭과 관련 컴포넌트(AiTradeTab)·훅(useAiBalance) 삭제. 헤더 카드는
aib 모의투자 요약 분기를 제거하고 항상 포트폴리오 요약을 표시.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:30:44 +09:00
ee5700dc95 feat(agent-office): 모바일 사이드패널 전체화면 토글 + music 에이전트 이미지 교체
모바일 바텀시트(Commands/Tasks)가 55vh로 작아 내용 확인이 불편 → 헤더에
전체화면 토글 버튼 추가(100dvh 확장, 데스크톱은 숨김). music 에이전트
이미지를 agent_music_2로 교체.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 08:30:38 +09:00
ec5fee8429 chore(agent-office): drop unused break state styling
Backend no longer emits the 'break' state (see web-backend
de8adae). Remove the matching entry from STATE_COLORS and the
.ao-card-dot.break CSS rule. Safe because AgentCard's unknown-state
fallback (DEFAULT_STATE_COLOR) handles any stray legacy value.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:44:58 +09:00
96cc5e7839 fix(agent-office): render TaskTab result_data when it's already an object
Old code assumed result_data was a JSON string and ran JSON.parse on it,
falling back to returning the value verbatim on parse error. When the
backend ships result_data as a dict (e.g. compose tasks return
{music_task_id, tracks}), JSON.parse threw, the catch returned the raw
object, and React threw error #31 'Objects are not valid as a React
child' the moment the user expanded the task row.

Extract formatResultData helper: object → JSON.stringify, JSON string
→ parse then pretty-print, plain string → as-is.

Regression tests cover all three input shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:38:06 +09:00
e6742e06ba fix(agent-office): unwrap {tasks}/{logs} response objects before .map
Backend returns {"tasks": [...]} and {"logs": [...]} but TaskTab and
LogTab stored the raw object and called .map on it, throwing
'l.map is not a function' the moment a user opened the Tasks or
Logs tab. Unwrap via Array.isArray check (also covers theoretical
bare-array responses).

Regression test for TaskTab covers all three response shapes:
wrapped object, bare array, and empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:34:08 +09:00
b713f00bf9 feat(agent-office): WS reconnect exponential backoff + status detail
- Replace fixed 3s reconnect with exponential backoff
  (1s/2s/4s/8s/16s/30s, capped). Reduces console noise when
  upstream WebSocket is blocked (e.g. DSM reverse proxy without
  WS upgrade headers).
- ws.onerror swallowed (onclose still schedules reconnect) so the
  browser stops printing an unhandled-error pair per attempt.
- Expose reconnectAttempt in hook; TopBar shows 'Connecting…'
  pre-first-attempt and 'Disconnected · 재연결 시도 #N' after.

Root cause of WS failure is upstream (curl proves the endpoint
itself is fine — see DSM reverse proxy WebSocket headers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:25:18 +09:00
0dce449124 chore(agent-office): convert agent PNGs to WebP (~93% smaller)
ffmpeg libwebp quality=85 compression_level=6.
Total: 11.8MB → 875KB (~11MB saved). Visually indistinguishable on
the card grid at the 9:16 image aspect.

PNG removals were already staged in the previous CommandTab commit;
this commit adds the 6 .webp replacements and points constants.js
imports at .webp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:58:12 +09:00
2c32659f6a fix(agent-office): useAgentManager reconnect via ref to satisfy lint
Previously connect's onclose handler referenced connect itself before
the useCallback declaration, triggering react-hooks/immutability. Hold
the latest connect in a ref (updated in useEffect) and call through it
on reconnect. Same runtime behavior, lint-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:58:04 +09:00
add2d8044c style(agent-office): neutral color for sidepanel state line
Was hardcoded #22c55e (green) regardless of actual state, making
error/break states look healthy. Switch to muted #94a3b8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:57:57 +09:00
2e9b0daec6 fix(agent-office): CommandTab approval state + blog→insta agent
- Approval card gated on 'waiting_approval' (was 'waiting'), matching
  the state useAgentManager emits — previously the approval UI was
  silently suppressed and pendingTask buttons unreachable
- QUICK_ACTIONS/PARAM_ACTIONS: drop blog (agent removed),
  add insta (extract / collect_trends / render)
- Regression test covers the three approval-card branches

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:57:41 +09:00
46589c05b1 feat(insta): 슬레이트 캐러셀 + 반응형 레이아웃 전면 개선
문제:
- 페이지 1~10 미리보기가 가로 overflow인데 시각 affordance 없어서 page 2~10 못 봄
- 슬레이트 목록(.ic-slates-grid)이 모바일에서 어색 + 카드 자체가 viewport 밖으로 밀림

수정:
- PagesStrip 컴포넌트 신설: 좌/우 chevron + page 인디케이터(3/10) + 양옆 fade gradient
  + 키보드 ←/→ + scroll-snap + 클릭 페이지 이동 + 활성 카드 핑크 테두리/scale
- .ic-page-img width를 clamp(140px, 42vw, 220px)로 viewport 비례
- .ic-slates-grid 모바일 2칸 강제, 640px+ 부터 auto-fill
- .ic-detail에 min-width: 0 + max-width: 100% (자식이 부모 안 밀게)
- .ic-layout grid-template-columns에 minmax(0, 1fr) — 자식 overflow 시 부모 안정
- .ic 모바일 좌우 padding 12px (768px+ 16px)
2026-05-18 07:30:25 +09:00
2a9c8cb619 style(agent-office): match card image to 941x1672 aspect, fill grid width
- Card image aspect-ratio 1/1 → 941/1672 (real image ratio, no crop)
- object-fit cover → contain (defensive against rounding)
- Drop card aspect-ratio so it grows from natural image+name height
- Drop grid max-width 720px so grid fills the viewport width

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 00:00:30 +09:00
bcaf217b72 feat(agent-office): commit agent character images
6 PNGs for 5 active agents + 1 shared placeholder. Required by
constants.js imports; without these the build resolves them from
local disk but a clean clone or NAS deploy would 404.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:03:46 +09:00
18e309a14b feat(agent-office): replace canvas office with 3x3 agent grid
- AgentOffice renders TopBar + AgentGrid + dynamic right panel
- Right panel: SidePanel (active) / EmptyDetailPanel (initial or placeholder)
- TopBar simplified to connected status only (theme/zoom dropped)
- Wire AgentGrid through useAgentManager state
- Remove canvas/ (9 files), useOfficeCanvas, office-map.json
- New CSS for grid cards (state dot, notification badge, accent border)
- Mobile: 2-column grid + bottom-sheet panel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:03:15 +09:00
80598cda93 refactor(agent-office): SidePanel uses central AGENT_META + image header
- emoji icon replaced with agent_{id}.png image
- AGENT_META imported from constants (single source of truth)
- blog removed, insta added (matches backend agent registry)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:59:25 +09:00
e49457ca46 feat(agent-office): EmptyDetailPanel for initial + placeholder views
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:58:06 +09:00
e04e2b010c feat(agent-office): AgentGrid renders 9 slots from GRID_SLOTS
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:57:20 +09:00
3fd923400f feat(agent-office): PlaceholderCard for unstaffed slots
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:56:02 +09:00
6d1f4914ca test(agent-office): cover pulse class for AgentCard state dot
Adds two tests verifying that working state adds the pulse class and
idle state does not. Pulse animation is part of the design spec §5
but was not covered by the original 8 tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:55:24 +09:00
1630109856 feat(agent-office): AgentCard component with state dot + badge
- state→color mapping via STATE_COLORS
- notification badge with 9+ overflow
- active prop for selected card border

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:52:34 +09:00
50d427e367 refactor(agent-office): derive ACTIVE_AGENT_IDS from GRID_SLOTS
Eliminates dual-write drift risk between ACTIVE_AGENT_IDS list
and GRID_SLOTS slot ordering. Single source of truth is now
GRID_SLOTS; ACTIVE_AGENT_IDS is computed from it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:50:42 +09:00
07f1d34f4c feat(agent-office): centralize AGENT_META + grid slot layout
- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- AGENT_META, GRID_SLOTS, STATE_COLORS in single constants module
- blog removed (replaced by insta)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:47:47 +09:00
d2838dfb7a docs(agent-office): implementation plan for 3x3 grid redesign
11 tasks covering AGENT_META centralization, AgentCard/PlaceholderCard/
AgentGrid/EmptyDetailPanel new components, SidePanel image header,
TopBar simplification, canvas removal, build + manual verification.

TDD for pure logic (constants, AgentCard); visual verification for layout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:36:52 +09:00
ce09f804b6 docs(agent-office): 3x3 grid redesign design spec
Replace pixel-office canvas with 3x3 agent image grid.
- 5 active agents (stock/music/insta/realestate/lotto) + 4 placeholders
- Drop blog from AGENT_META, insta replaces it
- New assets dir: src/pages/agent-office/assets/agents/
- Remove canvas/ dir + useOfficeCanvas + office-map.json
- Keep useAgentManager (WebSocket) + 4-tab SidePanel

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 20:26:57 +09:00
534ded59e8 docs(signal-v2): amend spread formula to absolute (q90-q10) for Chronos-bolt zero-shot reality
Phase 0 spec §6.1 originally specified relative spread (q90-q10)/median < 0.6.
Phase 3b smoke (005930: median=-0.59%, q90-q10=15.3%) revealed Chronos-bolt
zero-shot median frequently sits near zero, causing relative spread to explode
(15.3/0.0059 ≈ 25) and reject every signal. Absolute spread (0.153 < 0.6)
preserves the threshold semantic and keeps Phase 7 IC validation tractable.

Phase 4 spec §4.2 + Phase 0 §6.1 both amended with cross-reference.
chronos_predictor.py conf calculation unchanged — monotonic mapping there
is independent of hard-gate semantics.
2026-05-17 13:10:50 +09:00
f4b78da176 docs(signal-v2): Phase 4 implementation plan — 4 tasks TDD
Task 1: foundation (config 6 env + state.signals)
Task 2: signal_generator + 9 unit tests (TDD)
Task 3: pull_worker + main.py integration + 1 test
Task 4: user manual (.env optional + smoke + push)

10 new tests, total 55 signal_v2 tests. ~3-5 days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:52:13 +09:00
aeeab6704f fix(insta): SlateDetail JSON 객체 렌더 오류 + 카드 생성 시 자동 스크롤
(1) React error #31 fix: cover_copy/cta_copy는 객체({headline,body,accent_color}),
    body_copies는 배열 — 직접 {slate.cover_copy}로 렌더하면 에러. 필드별로
    분해 렌더하고, 10페이지 전체 카피(커버 1 + 본문 8 + CTA 1)를 detail에
    노출하도록 SlateDetail 확장.

(2) UX: handleCreateSlate 시작 시 window.scrollTo(0, 0)로 상단 progress 배너
    노출 보장. KeywordsPanel의 🎴 버튼도 부모 handleCreateSlate 위임으로
    통합 — Trends/Cards 양쪽 어디서 눌러도 동일 흐름(배너 + 자동 미리보기).

(3) KeywordsPanel의 자체 slatePoll 제거 — 상단 ic-slate-progress 배너로
    일원화하여 중복 진행 표시 회피.
2026-05-17 12:51:26 +09:00
6222b56716 feat(insta): trends 카드 생성 시 progress 배너 + 자동 미리보기 전환
Trends 탭의 🎴 버튼 클릭이 silent로 끝나 사용자에게 무동작처럼 보이던
이슈 fix. handleCreateSlate를 3초 간격 폴링으로 확장 (최대 8분):

- 시작/진행/성공/실패 상단 배너로 시각화
- 카드 생성 완료 시 자동으로 Cards 탭 전환 + 새 슬레이트 자동 선택
  → SlateDetail이 카피·이미지 미리보기 즉시 표시
- 실패 시 에러 메시지 + 클릭으로 dismiss
- "Claude 카피 추론 + Playwright 카드 10장 생성 중 (3~7분)" 안내 문구
2026-05-17 12:41:04 +09:00
9e9eed2162 docs(signal-v2): Phase 4 signal generator spec
매수/매도 룰 (Phase 0 spec §6.1-§6.3) + confidence_webai 공식
(chronos*0.5 + minute*0.3 + screener*0.2) + SignalDedup 24h. 6 env
외부화 (STOP_LOSS/TAKE_PROFIT/SPREAD/BID_RATIO/CONFIDENCE/MIN_MOMENTUM).
state.signals = Phase 0 spec §5.2 schema. 10 new tests.

brainstorming 6 decisions: scope=A(생성만) / trigger=A(매 cycle) /
minute_score=A(linear 5-level) / thresholds=A+(6 env) / state=A(spec §5.2) /
test=A(9 unit + 1 integration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:40:24 +09:00
06affd9614 feat(insta): swap google_trends source for youtube_trending (Google Trends API 폐기 대응) 2026-05-17 11:54:10 +09:00
161 changed files with 9114 additions and 2308 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,817 @@
# Signal V2 Phase 4 — Signal Generator Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** signal_v2 에 매수/매도 신호 생성 레이어 추가. Phase 2/3a/3b 의 모든 state 산출 → Phase 0 spec §6.1-§6.3 룰 → `state.signals[ticker]` (Phase 0 spec §5.2 schema) + `SignalDedup` 24h 차단.
**Architecture:** 순수 함수 `generate_signals(state, dedup, settings)` 가 매 분봉 cycle 후 호출. 매수 (Hard gate 4 조건 + soft confidence > 0.7) + 매도 (손절>이상>익절 우선순위). 6 env 외부화 (운영 튜닝).
**Tech Stack:** Python 순수 함수 / pytest / SignalDedup (Phase 2) / 외부 의존성 없음
**Spec:** `web-ui/docs/superpowers/specs/2026-05-17-signal-v2-phase4-signal-generator.md`
---
## 파일 구조
| 파일 | 책임 |
|------|------|
| `signal_v2/config.py` | (수정) Settings 에 6 env field 추가 |
| `signal_v2/state.py` | (수정) PollState `signals` field 추가 |
| `signal_v2/signal_generator.py` | (신규) `generate_signals(state, dedup, settings)` + 8 helper |
| `signal_v2/pull_worker.py` | (수정) `poll_loop` signature + 매 cycle 후 `generate_signals` 호출 |
| `signal_v2/main.py` | (수정) lifespan 의 poll_task 호출에 `dedup` + `settings` 전달 |
| `signal_v2/tests/test_signal_generator.py` | (신규) 9 단위 케이스 |
| `signal_v2/tests/test_pull_worker.py` | (수정) integration 1 케이스 추가 |
7 파일 변경, **10 신규 테스트** (45 → 55).
---
## Task 순서
```
Task 1: foundation (config 6 env + state signals field)
Task 2: signal_generator.py + 9 단위 tests (TDD)
Task 3: pull_worker + main.py 통합 + 1 integration test
Task 4: 사용자 수동 (.env optional + smoke + push)
```
---
### Task 1: foundation (config + state)
**Files:**
- Modify: `web-ai/signal_v2/config.py`
- Modify: `web-ai/signal_v2/state.py`
- [ ] **Step 1: Update config.py with 6 new fields**
Read `web-ai/signal_v2/config.py`. Add 6 fields to Settings (after `chronos_model` field, before properties):
```python
stop_loss_pct: float = field(
default_factory=lambda: float(os.getenv("STOP_LOSS_PCT", "-0.07"))
)
take_profit_pct: float = field(
default_factory=lambda: float(os.getenv("TAKE_PROFIT_PCT", "0.15"))
)
chronos_spread_threshold: float = field(
default_factory=lambda: float(os.getenv("CHRONOS_SPREAD_THRESHOLD", "0.6"))
)
asking_bid_ratio_threshold: float = field(
default_factory=lambda: float(os.getenv("ASKING_BID_RATIO_THRESHOLD", "0.6"))
)
confidence_threshold: float = field(
default_factory=lambda: float(os.getenv("CONFIDENCE_THRESHOLD", "0.7"))
)
min_momentum_for_buy: str = field(
default_factory=lambda: os.getenv("MIN_MOMENTUM_FOR_BUY", "strong_up")
)
```
- [ ] **Step 2: Update state.py with signals field**
Read `web-ai/signal_v2/state.py`. Add `signals` field to PollState (after `minute_momentum`):
```python
signals: dict[str, dict] = field(default_factory=dict)
```
- [ ] **Step 3: Smoke import test**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -c "from signal_v2.config import get_settings; from signal_v2.state import state; s = get_settings(); print(f'stop_loss={s.stop_loss_pct}, conf_threshold={s.confidence_threshold}, min_momentum={s.min_momentum_for_buy}'); print(state)"
```
Expected: `stop_loss=-0.07, conf_threshold=0.7, min_momentum=strong_up` + state print with `signals={}`.
- [ ] **Step 4: Run existing tests — no regression**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 45 passed.
- [ ] **Step 5: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/config.py signal_v2/state.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): foundation — 6 env thresholds + state.signals
config.py: STOP_LOSS_PCT / TAKE_PROFIT_PCT / CHRONOS_SPREAD_THRESHOLD /
ASKING_BID_RATIO_THRESHOLD / CONFIDENCE_THRESHOLD / MIN_MOMENTUM_FOR_BUY
env vars with sensible defaults (Phase 0 spec §6.1-§6.2 values).
state.py: PollState.signals dict[ticker, signal_body] for Phase 5 input.
45 existing tests still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 2: signal_generator.py + 9 단위 tests
**Files:**
- Create: `web-ai/signal_v2/signal_generator.py`
- Create: `web-ai/signal_v2/tests/test_signal_generator.py`
- [ ] **Step 1: Write 9 failing tests**
Create `web-ai/signal_v2/tests/test_signal_generator.py`:
```python
"""Tests for signal_generator."""
from collections import deque
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from signal_v2.signal_generator import generate_signals
from signal_v2.state import PollState
def _settings(**overrides):
"""Build a Settings-like object for tests (avoid env)."""
defaults = dict(
stop_loss_pct=-0.07,
take_profit_pct=0.15,
chronos_spread_threshold=0.6,
asking_bid_ratio_threshold=0.6,
confidence_threshold=0.7,
min_momentum_for_buy="strong_up",
)
defaults.update(overrides)
m = MagicMock()
for k, v in defaults.items():
setattr(m, k, v)
return m
def _make_state_with_buy_candidate(
ticker="005930", name="삼성전자", rank=1,
chronos_median=0.02, chronos_q10=-0.01, chronos_q90=0.04, chronos_conf=0.85,
momentum="strong_up", bid_ratio=0.7, current_price=78500,
):
state = PollState()
state.screener_preview = {"items": [{"ticker": ticker, "name": name}]}
state.chronos_predictions[ticker] = {
"median": chronos_median, "q10": chronos_q10, "q90": chronos_q90,
"conf": chronos_conf, "as_of": "2026-05-17T16:00:00+09:00",
}
state.minute_momentum[ticker] = momentum
state.asking_price[ticker] = {
"bid_total": int(bid_ratio * 1000),
"ask_total": int((1 - bid_ratio) * 1000),
"bid_ratio": bid_ratio,
"current_price": current_price,
"as_of": "2026-05-17T16:00:01+09:00",
}
return state
def _make_state_with_holding(
ticker="005930", name="삼성전자",
pnl_pct=0.0, avg_price=75000, current_price=75000,
):
state = PollState()
state.portfolio = {"holdings": [{
"ticker": ticker, "name": name,
"avg_price": avg_price, "current_price": current_price,
"pnl_pct": pnl_pct, "profit_rate": pnl_pct * 100,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
return state
@pytest.fixture
def dedup_mock():
d = MagicMock()
d.is_recent.return_value = False
return d
def test_buy_signal_when_all_conditions_pass_and_confidence_high(dedup_mock):
state = _make_state_with_buy_candidate()
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "buy"
assert sig["confidence_webai"] > 0.7
dedup_mock.record.assert_called()
def test_silent_when_chronos_median_negative(dedup_mock):
state = _make_state_with_buy_candidate(chronos_median=-0.01)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_distribution_spread_too_wide(dedup_mock):
# spread = (0.5 - (-0.5)) / max(|0.001|, 0.001) = 1000 → > 0.6
state = _make_state_with_buy_candidate(
chronos_median=0.001, chronos_q10=-0.5, chronos_q90=0.5,
)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_momentum_not_strong_up(dedup_mock):
state = _make_state_with_buy_candidate(momentum="weak_up")
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_bid_ratio_below_threshold(dedup_mock):
state = _make_state_with_buy_candidate(bid_ratio=0.5)
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
def test_silent_when_confidence_below_threshold(dedup_mock):
# chronos_conf low + rank=20 → confidence < 0.7
state = _make_state_with_buy_candidate(chronos_conf=0.3)
# add 19 fake items to push rank to 20
state.screener_preview["items"] = (
[{"ticker": f"FAKE{i:03d}"} for i in range(19)]
+ [{"ticker": "005930", "name": "삼성전자"}]
)
generate_signals(state, dedup_mock, _settings())
# confidence_webai = 0.3*0.5 + 1.0*0.3 + 0.05*0.2 = 0.15 + 0.3 + 0.01 = 0.46 < 0.7
assert "005930" not in state.signals
def test_sell_signal_when_stop_loss_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=-0.08, current_price=69000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 1.0 # 손절선 즉시
assert sig["pnl_pct"] == -0.08
def test_sell_signal_when_take_profit_triggered(dedup_mock):
state = _make_state_with_holding(pnl_pct=0.16, current_price=87000, avg_price=75000)
generate_signals(state, dedup_mock, _settings())
assert "005930" in state.signals
sig = state.signals["005930"]
assert sig["action"] == "sell"
assert sig["confidence_webai"] == 0.6 # 익절선 검토 알림
def test_silent_when_dedup_recently_sent(dedup_mock):
state = _make_state_with_buy_candidate()
dedup_mock.is_recent.return_value = True # dedup 차단
generate_signals(state, dedup_mock, _settings())
assert "005930" not in state.signals
dedup_mock.record.assert_not_called()
```
- [ ] **Step 2: Run tests to verify FAIL**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_signal_generator.py -v 2>&1 | tail -10
```
Expected: ImportError (signal_v2.signal_generator missing).
- [ ] **Step 3: Implement signal_generator.py**
Create `web-ai/signal_v2/signal_generator.py`:
```python
"""Phase 4 — 매수/매도 신호 생성.
순수 함수 generate_signals(state, dedup, settings). state 를 mutate.
"""
from __future__ import annotations
import logging
from datetime import datetime
from zoneinfo import ZoneInfo
logger = logging.getLogger(__name__)
KST = ZoneInfo("Asia/Seoul")
# 분봉 모멘텀 → linear score
MOMENTUM_SCORES = {
"strong_up": 1.0,
"weak_up": 0.7,
"neutral": 0.5,
"weak_down": 0.3,
"strong_down": 0.0,
}
def generate_signals(state, dedup, settings) -> None:
"""Phase 4 entry — state mutating. 매수/매도 룰 적용."""
_evaluate_buy_signals(state, dedup, settings)
_evaluate_sell_signals(state, dedup, settings)
# ----- 매수 -----
def _evaluate_buy_signals(state, dedup, settings) -> None:
candidates = _buy_candidates(state)
for ticker, name, rank in candidates:
if not _check_buy_hard_gate(state, ticker, settings):
continue
confidence = _compute_buy_confidence(state, ticker, rank)
if confidence <= settings.confidence_threshold:
continue
if dedup.is_recent(ticker, "buy", within_hours=24):
continue
state.signals[ticker] = _build_buy_signal(state, ticker, name, rank, confidence)
dedup.record(ticker, "buy", confidence=confidence)
def _buy_candidates(state) -> list[tuple[str, str, int | None]]:
"""screener Top-N (rank 1..N) + portfolio (rank=None)."""
candidates: list[tuple[str, str, int | None]] = []
seen: set[str] = set()
# Screener Top-N
if state.screener_preview is not None:
for i, item in enumerate(state.screener_preview.get("items", [])):
ticker = item.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
name = item.get("name", ticker)
candidates.append((ticker, name, i + 1))
# Portfolio holdings
if state.portfolio is not None:
for h in state.portfolio.get("holdings", []):
ticker = h.get("ticker")
if not ticker or ticker in seen:
continue
seen.add(ticker)
candidates.append((ticker, h.get("name", ticker), None))
return candidates
def _check_buy_hard_gate(state, ticker: str, settings) -> bool:
pred = state.chronos_predictions.get(ticker)
if pred is None or pred["median"] <= 0:
return False
spread = (pred["q90"] - pred["q10"]) / max(abs(pred["median"]), 0.001)
if spread >= settings.chronos_spread_threshold:
return False
momentum = state.minute_momentum.get(ticker)
if momentum != settings.min_momentum_for_buy:
return False
ap = state.asking_price.get(ticker)
if ap is None or ap["bid_ratio"] < settings.asking_bid_ratio_threshold:
return False
return True
def _compute_buy_confidence(state, ticker: str, rank: int | None) -> float:
pred = state.chronos_predictions[ticker]
chronos_conf = pred["conf"]
minute_score = MOMENTUM_SCORES.get(state.minute_momentum.get(ticker, "neutral"), 0.5)
screener_norm = 1 - (rank - 1) / 20 if rank is not None else 0.0
return chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
def _build_buy_signal(state, ticker: str, name: str, rank: int | None, confidence: float) -> dict:
ap = state.asking_price[ticker]
pred = state.chronos_predictions[ticker]
return {
"ticker": ticker,
"name": name,
"action": "buy",
"confidence_webai": confidence,
"current_price": ap["current_price"],
"avg_price": None,
"pnl_pct": None,
"context": _build_context(state, ticker, rank),
"as_of": datetime.now(KST).isoformat(),
}
# ----- 매도 -----
def _evaluate_sell_signals(state, dedup, settings) -> None:
if state.portfolio is None:
return
for holding in state.portfolio.get("holdings", []):
ticker = holding.get("ticker")
if not ticker:
continue
sell = _try_stop_loss(state, holding, settings)
if sell is None:
sell = _try_anomaly(state, holding, settings)
if sell is None:
sell = _try_take_profit(state, holding, settings)
if sell is None:
continue
if dedup.is_recent(ticker, "sell", within_hours=24):
continue
state.signals[ticker] = sell
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
def _try_stop_loss(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl >= settings.stop_loss_pct:
return None
return _build_sell_signal(state, holding, confidence=1.0, reason="stop_loss")
def _try_take_profit(state, holding: dict, settings) -> dict | None:
pnl = holding.get("pnl_pct")
if pnl is None or pnl <= settings.take_profit_pct:
return None
return _build_sell_signal(state, holding, confidence=0.6, reason="take_profit")
def _try_anomaly(state, holding: dict, settings) -> dict | None:
ticker = holding["ticker"]
pred = state.chronos_predictions.get(ticker)
if pred is None or pred["median"] >= -0.01:
return None
momentum = state.minute_momentum.get(ticker)
if momentum != "strong_down":
return None
ap = state.asking_price.get(ticker)
if ap is None:
return None
if ap["bid_ratio"] > (1 - settings.asking_bid_ratio_threshold):
return None # 매도세 60% 미만
minute_score = 1.0 - MOMENTUM_SCORES.get(momentum, 0.5) # 반전
confidence = pred["conf"] * 0.5 + minute_score * 0.3 + 1.0 * 0.2
if confidence <= settings.confidence_threshold:
return None
return _build_sell_signal(state, holding, confidence=confidence, reason="anomaly")
def _build_sell_signal(state, holding: dict, confidence: float, reason: str) -> dict:
ticker = holding["ticker"]
return {
"ticker": ticker,
"name": holding.get("name", ticker),
"action": "sell",
"confidence_webai": confidence,
"current_price": holding.get("current_price"),
"avg_price": holding.get("avg_price"),
"pnl_pct": holding.get("pnl_pct"),
"context": _build_context(state, ticker, rank=None, sell_reason=reason),
"as_of": datetime.now(KST).isoformat(),
}
# ----- Context -----
def _build_context(state, ticker: str, rank: int | None, sell_reason: str | None = None) -> dict:
pred = state.chronos_predictions.get(ticker) or {}
ap = state.asking_price.get(ticker) or {}
news_item = _find_news_sentiment(state, ticker)
screener_scores = _find_screener_scores(state, ticker)
context: dict = {
"chronos_pred_1d": pred.get("median"),
"chronos_pred_conf": pred.get("conf"),
"chronos_q10": pred.get("q10"),
"chronos_q90": pred.get("q90"),
"screener_rank": rank,
"screener_scores": screener_scores,
"minute_momentum": state.minute_momentum.get(ticker),
"asking_bid_ratio": ap.get("bid_ratio"),
"news_sentiment": news_item.get("score") if news_item else None,
"news_reason": news_item.get("reason") if news_item else None,
}
if sell_reason is not None:
context["sell_reason"] = sell_reason
return context
def _find_news_sentiment(state, ticker: str) -> dict | None:
if state.news_sentiment is None:
return None
for item in state.news_sentiment.get("items", []):
if item.get("ticker") == ticker:
return item
return None
def _find_screener_scores(state, ticker: str) -> dict | None:
if state.screener_preview is None:
return None
for item in state.screener_preview.get("items", []):
if item.get("ticker") == ticker:
return item.get("scores")
return None
```
- [ ] **Step 4: Run tests to verify PASS**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_signal_generator.py -v 2>&1 | tail -15
```
Expected: 9 passed.
Full suite:
```bash
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 54 passed.
If any test fails, examine the assertion + impl. Common gotchas:
- Confidence calculation order — chronos*0.5 + minute*0.3 + screener*0.2
- Stop loss `<` (strict) vs `<=` — spec says "도달 시" so use `<` strict
- screener_norm when rank=None → 0.0 (not 1.0)
- [ ] **Step 5: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/signal_generator.py signal_v2/tests/test_signal_generator.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): signal_generator + 9 unit tests
generate_signals(state, dedup, settings) → state mutating:
- Buy: screener Top-N + portfolio. Hard gate (chronos median > 0 +
spread < 0.6 + momentum strong_up + bid_ratio >= 0.6) + soft
confidence (chronos*0.5 + minute*0.3 + screener*0.2) > 0.7.
- Sell: portfolio only. Priority stop_loss > anomaly > take_profit.
Stop loss confidence 1.0 (immediate), take_profit 0.6 (review).
- SignalDedup 24h via dedup.is_recent/record per (ticker, action).
- State signal dict matches Phase 0 spec §5.2 schema.
54 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 3: pull_worker + main.py integration + 1 test
**Files:**
- Modify: `web-ai/signal_v2/pull_worker.py`
- Modify: `web-ai/signal_v2/main.py`
- Modify: `web-ai/signal_v2/tests/test_pull_worker.py`
- [ ] **Step 1: Write failing integration test**
Append to `web-ai/signal_v2/tests/test_pull_worker.py`:
```python
def test_poll_loop_calls_generate_signals_after_cycle(monkeypatch):
"""매 cycle 후 generate_signals 호출 + state.signals 갱신."""
from unittest.mock import MagicMock
from signal_v2.state import PollState
state = PollState()
state.portfolio = {"holdings": [{
"ticker": "005930", "name": "삼성전자",
"avg_price": 75000, "current_price": 69000,
"pnl_pct": -0.08, "profit_rate": -8.0,
"quantity": 100, "broker": "키움",
}]}
state.screener_preview = {"items": []}
dedup = MagicMock()
dedup.is_recent.return_value = False
settings = MagicMock()
settings.stop_loss_pct = -0.07
settings.take_profit_pct = 0.15
settings.chronos_spread_threshold = 0.6
settings.asking_bid_ratio_threshold = 0.6
settings.confidence_threshold = 0.7
settings.min_momentum_for_buy = "strong_up"
from signal_v2.signal_generator import generate_signals
# Call generate_signals directly to verify state mutation through the public API.
generate_signals(state, dedup, settings)
# Stop loss should trigger
assert "005930" in state.signals
assert state.signals["005930"]["action"] == "sell"
assert state.signals["005930"]["confidence_webai"] == 1.0
dedup.record.assert_called_with("005930", "sell", confidence=1.0)
```
- [ ] **Step 2: Run test to verify PASS (signal_generator from Task 2 already exists)**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests/test_pull_worker.py::test_poll_loop_calls_generate_signals_after_cycle -v 2>&1 | tail -10
```
Expected: PASS (test exercises generate_signals directly — public API integration).
- [ ] **Step 3: Update pull_worker.py — poll_loop signature + cycle integration**
Read `web-ai/signal_v2/pull_worker.py`. Modify the `poll_loop` signature to accept dedup + settings:
```python
async def poll_loop(
client, state, shutdown,
kis_client=None, chronos=None,
dedup=None, settings=None,
) -> None:
"""...existing docstring..."""
logger.info("poll_loop started")
while not shutdown.is_set():
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
try:
await _run_polling_cycle(client, state, kis_client=kis_client)
except Exception:
logger.exception("poll cycle failed")
try:
update_minute_momentum_for_all(state)
except Exception:
logger.exception("minute momentum update failed")
if _is_post_close_trigger(now) and chronos is not None and kis_client is not None:
try:
await _run_post_close_cycle(kis_client, chronos, state)
except Exception:
logger.exception("post-close cycle failed")
# Phase 4: generate signals
if dedup is not None and settings is not None:
try:
from signal_v2.signal_generator import generate_signals
generate_signals(state, dedup, settings)
except Exception:
logger.exception("generate_signals failed")
interval = _next_interval(now)
try:
await asyncio.wait_for(shutdown.wait(), timeout=interval)
break
except asyncio.TimeoutError:
continue
logger.info("poll_loop ended")
```
- [ ] **Step 4: Update main.py — pass dedup + settings to poll_loop**
Read `web-ai/signal_v2/main.py`. Find the `asyncio.create_task(poll_loop(...))` call inside `lifespan` and add `dedup` + `settings` params:
```python
_ctx.poll_task = asyncio.create_task(
poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
chronos=_ctx.chronos,
dedup=_ctx.dedup,
settings=settings,
)
)
```
- [ ] **Step 5: Run full test suite**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
python -m pytest signal_v2/tests -q 2>&1 | tail -3
```
Expected: 55 passed (54 + 1 new integration).
- [ ] **Step 6: Commit**
```bash
cd /c/Users/jaeoh/Desktop/workspace/web-ai
git add signal_v2/pull_worker.py signal_v2/main.py signal_v2/tests/test_pull_worker.py
git commit -m "$(cat <<'EOF'
feat(signal_v2-phase4): pull_worker + main.py integrate signal generator
poll_loop signature now accepts dedup + settings. After each cycle
(stock pull + minute momentum + post-close), call generate_signals
to populate state.signals. main.py lifespan passes _ctx.dedup and
settings to poll_loop.
1 integration test added. 55 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
### Task 4: 사용자 수동 — .env optional + smoke + push
**This task requires user action.**
- [ ] **Step 1: .env optional**
6 env 의 default 가 Phase 0 spec 값과 동일 — `.env` 변경 불필요. 운영 검증 후 조정 시:
```
STOP_LOSS_PCT=-0.07
TAKE_PROFIT_PCT=0.15
CHRONOS_SPREAD_THRESHOLD=0.6
ASKING_BID_RATIO_THRESHOLD=0.6
CONFIDENCE_THRESHOLD=0.7
MIN_MOMENTUM_FOR_BUY=strong_up
```
- [ ] **Step 2: signal_v2 재시작**
기존 signal_v2 가 가동 중이면 Ctrl+C 후:
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai\signal_v2
.\start.bat
```
기대: 정상 시작 (signal_generator 자동 호출 — 매 cycle 마다).
- [ ] **Step 3: state.signals 검증 (수동)**
운영 시간대라면 cycle 진행 + state.signals 채워질 수 있음. 수동 검증:
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai
python -c "
import asyncio
from signal_v2.config import get_settings
from signal_v2.kis_client import KISClient
from signal_v2.chronos_predictor import ChronosPredictor
from signal_v2.state import PollState
from signal_v2.rate_limit import SignalDedup
from signal_v2.pull_worker import _run_post_close_cycle, update_minute_momentum_for_all
from signal_v2.signal_generator import generate_signals
async def main():
s = get_settings()
kc = KISClient(app_key=s.kis_app_key, app_secret=s.kis_app_secret, account=s.kis_account, is_virtual=s.kis_is_virtual, v1_token_path=s.v1_token_path)
cp = ChronosPredictor(model_name=s.chronos_model)
dedup = SignalDedup(s.db_path)
state = PollState()
state.portfolio = {'holdings': [{'ticker': '005930', 'name': '삼성전자', 'avg_price': 75000, 'current_price': 78500, 'pnl_pct': 0.047, 'profit_rate': 4.67, 'quantity': 100, 'broker': '키움'}]}
state.screener_preview = {'items': []}
try:
await _run_post_close_cycle(kc, cp, state)
update_minute_momentum_for_all(state)
generate_signals(state, dedup, s)
print('Signals:', state.signals)
finally:
await kc.close()
asyncio.run(main())
"
```
Expected: `Signals: {}` (정상 — pnl_pct 0.047 은 손절/익절 트리거 안 함, 매수 조건 다 만족 어려움) 또는 일부 신호 dict.
- [ ] **Step 4: V1 무영향**
V1 정상 가동 확인.
- [ ] **Step 5: push**
```powershell
cd C:\Users\jaeoh\Desktop\workspace\web-ai
git push
```
- [ ] **Step 6: 결과 보고**
- Step 2 (signal_v2 시작): PASS / FAIL
- Step 3 (state.signals 검증): PASS — empty dict or 신호 결과 공유 / FAIL
- Step 4 (V1 무영향): PASS / FAIL
- Step 5 (push): PASS / FAIL
전체 PASS 시 **Phase 4 완료** → Phase 5 (agent-office /signal + Qwen3 + 이중 텔레그램) brainstorming.
---
## Self-Review
**1. Spec coverage:**
| Spec § | 요구사항 | Plan task |
|--------|----------|----------|
| §2 ① signal_generator | Task 2 ✅ |
| §2 ② config 6 env | Task 1 ✅ |
| §2 ③ state.signals | Task 1 ✅ |
| §2 ④ pull_worker integration | Task 3 ✅ |
| §2 ⑤ main.py lifespan | Task 3 ✅ |
| §2 ⑥ 10 tests | Task 2 (9) + Task 3 (1) = 10 ✅ |
| §4 매수 룰 + confidence | Task 2 (_check_buy_hard_gate + _compute_buy_confidence) ✅ |
| §5 매도 룰 + dedup | Task 2 (_try_stop_loss/anomaly/take_profit + dedup.is_recent/record) ✅ |
| §6 state 통합 + pull_worker | Task 1 + Task 3 ✅ |
| §7 signal_generator 구조 | Task 2 Step 3 (8 helpers) ✅ |
| §8 10 테스트 케이스 | Task 2-3 ✅ |
| §9 DoD 8 항목 | Task 1-4 합산 ✅ |
No gaps.
**2. Placeholder scan**: No "TBD" / "implement later". 각 step 의 코드 + 명령 모두 명시.
**3. Type consistency:**
- `generate_signals(state, dedup, settings) -> None` consistent Task 2 + Task 3 ✅
- `MOMENTUM_SCORES` 매핑 consistent (1.0/0.7/0.5/0.3/0.0) ✅
- Settings field names consistent Task 1 + Task 2 (stop_loss_pct, etc.) ✅
- PollState.signals dict[str, dict] consistent ✅
- helper signatures (_check_buy_hard_gate, _compute_buy_confidence, _try_stop_loss, _try_anomaly, _try_take_profit, _build_buy_signal, _build_sell_signal, _build_context) consistent ✅
Plan passes self-review.

View File

@@ -194,7 +194,7 @@ agent-office 가 web-ai 의 Ollama (Qwen3 14B Q4) 에 보내는 prompt 의 응
### 6.1 매수 신호 (screener Top-20 종목 대상) ### 6.1 매수 신호 (screener Top-20 종목 대상)
조건 (전부 충족): 조건 (전부 충족):
1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 (90-10 분위수 / 50 분위수) < 0.6 (좁은 분포 = 높은 conf) 1. Chronos-2 1-day quantile (median) 예측 > 0% 그리고 분포 폭 `q90 - q10` < 0.6 (절대 spread, 60% return 변동 미만 = 모델 확신; **Phase 4 amend 2026-05-17**: 기존 relative formula `(q90-q10)/median` 는 Chronos-bolt 의 median≈0 출력에서 거의 모든 신호 거부 → absolute spread 채택. 자세한 사유는 `2026-05-17-signal-v2-phase4-signal-generator.md` §4.2 참조)
2. 분봉 모멘텀 = `strong_up`: 2. 분봉 모멘텀 = `strong_up`:
- 5분봉 5개 연속 양봉 - 5분봉 5개 연속 양봉
- 거래량 > 평균 1.5배 - 거래량 > 평균 1.5배

View File

@@ -0,0 +1,345 @@
# Agent Office 그리드 재설계 — Design Spec
**Date:** 2026-05-17
**Author:** CEO (with Claude)
**Target:** `web-ui` `/agent-office` 페이지
---
## 1. 배경 & 목적
현재 `/agent-office` 페이지는 픽셀 사무실 Canvas 위에서 5명의 에이전트 캐릭터가 무의미하게 걸어다니는 형태다. 시각적 즐거움은 있으나 정보 밀도가 낮고, 각 에이전트가 무슨 일을 하는지 한눈에 파악하기 어렵다.
이를 **3x3 그리드** 기반의 정보 중심 UI로 재설계한다. 왼편에 9개의 에이전트 이미지 카드를 배치하고, 카드 클릭 시 오른편 패널에서 해당 에이전트의 명령·태스크·토큰·로그를 확인한다.
---
## 2. 범위 (Scope)
### In scope
- `src/pages/agent-office/AgentOffice.jsx` 전면 재작성 (Canvas → Grid)
- 그리드 카드 컴포넌트 신규 작성
- `SidePanel.jsx` 헤더 부분 수정 (emoji → 이미지)
- `SidePanel.jsx``AGENT_META`에서 `blog` 제거, `insta` 추가
- TopBar 단순화 (theme/zoom 컨트롤 제거)
- Canvas 관련 파일/디렉토리 전체 삭제
- 이미지 에셋 디렉토리 신설
### Out of scope
- 백엔드 변경 (현재 백엔드의 `insta` 에이전트는 이미 등록 완료, 추가 작업 불필요)
- 새 에이전트 추가 (4개 placeholder는 "준비 중" 표시만)
- 4탭 컨텐츠 (Commands/Tasks/Tokens/Logs) 로직 수정
---
## 3. 에이전트 구성
### 실제 작동 5명
| ID | 표시명 | 색상 | 역할 요약 |
|----|--------|------|-----------|
| `stock` | 주식 트레이더 | `#4488cc` | 주식 매매·뉴스 분석·포트폴리오 |
| `music` | 음악 프로듀서 | `#44aa88` | AI 음악 생성 |
| `insta` | 인스타 큐레이터 | `#d97706` | 매일 09:30 뉴스 수집 → 키워드 추출 → AI 카드 10장 생성 → 텔레그램 푸시 |
| `realestate` | 청약 애널리스트 | `#c026d3` | 부동산 청약 매칭·자치구 5티어 분석 |
| `lotto` | 로또 큐레이터 | `#ef4444` | 로또 번호 추천·브리핑 |
> `blog`는 `insta`로 대체됨. 기존 `SidePanel.jsx`의 `AGENT_META`에서 `blog` 항목 삭제 + `insta` 추가.
### Placeholder 4개
- ID 없음 (그리드 슬롯 인덱스 6/7/8/9로만 식별)
- 모두 동일하게 `agent_undetermined.png` + "준비 중" 라벨
- 클릭 시 정적 안내 패널 노출
---
## 4. 디렉토리 & 파일 구조
### 신설 디렉토리
```
src/pages/agent-office/assets/agents/
├── agent_stock.png (사용자 제공)
├── agent_music.png (사용자 제공)
├── agent_insta.png (사용자 제공)
├── agent_realestate.png (사용자 제공)
├── agent_lotto.png (사용자 제공)
└── agent_undetermined.png (사용자 제공, 4 placeholder 공유)
```
### 파일명 규칙
`agent_{id}.png` 형식. `{id}`는 백엔드의 agent_id와 일치 (소문자, underscore).
### 권장 이미지 사양
- 정사각형 (예: 512x512)
- PNG (투명 배경 허용)
- 카드 표시 시 `object-fit: cover`로 정사각 크롭
### 삭제 대상
```
src/pages/agent-office/
├── canvas/ ← 전체 삭제
│ ├── themes.js
│ ├── FurnitureRenderer.js
│ ├── ProceduralSprite.js
│ ├── AgentSprite.js
│ ├── SpriteLoader.js
│ ├── OverlayRenderer.js
│ ├── Pathfinder.js
│ ├── OfficeRenderer.js
│ └── TileMap.js
├── hooks/
│ └── useOfficeCanvas.js ← 삭제
└── assets/
└── office-map.json ← 삭제
```
### 유지 대상
```
src/pages/agent-office/
├── AgentOffice.jsx ← 재작성
├── AgentOffice.css ← 재작성
├── hooks/
│ └── useAgentManager.js ← 그대로 (WebSocket 로직)
└── components/
├── TopBar.jsx ← 단순화 (theme/zoom 제거)
├── SidePanel.jsx ← 헤더 수정 + AGENT_META 갱신
├── CommandTab.jsx ← 그대로
├── TaskTab.jsx ← 그대로
├── TokenTab.jsx ← 그대로
└── LogTab.jsx ← 그대로
```
### 신규 컴포넌트
```
src/pages/agent-office/components/
├── AgentGrid.jsx ← 3x3 그리드 래퍼
├── AgentCard.jsx ← 카드 1개 (image + state dot + badge + name)
├── PlaceholderCard.jsx ← "준비 중" 카드
└── EmptyDetailPanel.jsx ← 초기 안내 / placeholder 클릭 시 안내
```
---
## 5. 레이아웃
### 전체 화면 구조
```
┌─────────────────────────────────────────────────────────────┐
│ TopBar (connected status only) │
├──────────────────────────────────┬──────────────────────────┤
│ │ │
│ AgentGrid (3x3) │ Right Panel │
│ ┌──────┬──────┬──────┐ │ │
│ │stock │music │insta │ │ ┌─ active 선택 시 ─┐ │
│ ├──────┼──────┼──────┤ │ │ SidePanel │ │
│ │realE │lotto │ ?? │ │ │ - 헤더(이미지+이름)│ │
│ ├──────┼──────┼──────┤ │ │ - 4 tabs │ │
│ │ ?? │ ?? │ ?? │ │ └──────────────────┘ │
│ └──────┴──────┴──────┘ │ │
│ │ ┌─ placeholder 선택 ─┐ │
│ │ │ "준비 중인 에이전트"│ │
│ │ └────────────────────┘ │
│ │ │
│ │ ┌─ 초기(미선택) ──────┐ │
│ │ │ "에이전트를 선택…" │ │
│ │ └────────────────────┘ │
└──────────────────────────────────┴──────────────────────────┘
```
### 그리드 슬롯 순서 (좌→우, 위→아래)
| Index | Slot |
|-------|------|
| 1 (행1·열1) | `stock` |
| 2 (행1·열2) | `music` |
| 3 (행1·열3) | `insta` |
| 4 (행2·열1) | `realestate` |
| 5 (행2·열2) | `lotto` |
| 6 (행2·열3) | placeholder |
| 7 (행3·열1) | placeholder |
| 8 (행3·열2) | placeholder |
| 9 (행3·열3) | placeholder |
### AgentCard 시각 구조
```
┌─────────────────────┐
│ ● state [③] │ ← 상태 dot(좌상, image 약간 위) + 알림 뱃지(우상)
│ ┌───────────────┐ │
│ │ │ │
│ │ agent_xxx │ │ ← 정사각 이미지 (object-fit: cover)
│ │ .png │ │
│ │ │ │
│ └───────────────┘ │
│ 주식 트레이더 │ ← display_name
└─────────────────────┘
```
#### 상태 dot
| state | color | 동작 |
|-------|-------|------|
| `idle` | `#6b7280` (회색) | 정적 |
| `working` | `#22c55e` (초록) | pulse 애니메이션 |
| `error` | `#ef4444` (빨강) | 정적 |
| `waiting_approval` | `#f59e0b` (주황) | pulse |
| `break` | `#94a3b8` (밝은 회색) | 정적 |
상태 dot은 카드의 좌상단, 이미지보다 약간 위쪽에 위치 (이미지 영역 바깥 또는 모서리 살짝 걸침).
#### 알림 뱃지
- `notifications[agentId] > 0`일 때만 우상단에 표시
- 빨강 배경에 흰 숫자 (count > 9면 "9+")
- 카드 클릭 시 자동으로 0으로 리셋 (`clearNotifications` 호출 — 기존 로직 재사용)
---
## 6. 데이터 플로우
```
useAgentManager (그대로 유지)
├── WebSocket /api/agent-office/ws
├── agents: { [id]: { state, detail, task_id } }
├── notifications: { [id]: count }
├── pendingTasks: [...]
├── connected: bool
└── refreshTrigger: number
AgentOffice.jsx
├── agents → AgentGrid에 전달 → 각 AgentCard가 state로 dot 색상 결정
├── notifications → 각 AgentCard가 badge 표시
├── selectedAgent (local state): string | null | "placeholder"
└── 카드 클릭 시 setSelectedAgent + clearNotifications
Right Panel 분기
├── selectedAgent === null → EmptyDetailPanel (초기 안내)
├── selectedAgent === "placeholder"→ EmptyDetailPanel ("준비 중" 메시지)
└── selectedAgent ∈ active 5명 → SidePanel (4탭, 기존 로직)
```
---
## 7. SidePanel 수정 사항
### AGENT_META 갱신
```js
// src/pages/agent-office/components/SidePanel.jsx
import stockImg from '../assets/agents/agent_stock.png';
import musicImg from '../assets/agents/agent_music.png';
import instaImg from '../assets/agents/agent_insta.png';
import realestateImg from '../assets/agents/agent_realestate.png';
import lottoImg from '../assets/agents/agent_lotto.png';
const AGENT_META = {
stock: { displayName: '주식 트레이더', image: stockImg, color: '#4488cc' },
music: { displayName: '음악 프로듀서', image: musicImg, color: '#44aa88' },
insta: { displayName: '인스타 큐레이터', image: instaImg, color: '#d97706' },
realestate: { displayName: '청약 애널리스트', image: realestateImg, color: '#c026d3' },
lotto: { displayName: '로또 큐레이터', image: lottoImg, color: '#ef4444' }
};
// blog 항목 삭제
```
### 헤더 시각 변경
```jsx
// 변경 전: emoji icon
<div className="ao-sidepanel-icon" style={{ background: meta.color }}>
{meta.emoji}
</div>
// 변경 후: 이미지
<div className="ao-sidepanel-icon" style={{ borderColor: meta.color }}>
<img src={meta.image} alt={meta.displayName} />
</div>
```
4탭(Commands/Tasks/Tokens/Logs) 본체 로직은 손대지 않음.
---
## 8. CSS 토큰 (제안)
```css
:root {
--ao-bg: #0f172a;
--ao-card-bg: #1e293b;
--ao-card-border: #334155;
--ao-card-border-active: #60a5fa;
--ao-text: #e2e8f0;
--ao-text-muted: #94a3b8;
--ao-grid-gap: 16px;
--ao-card-radius: 12px;
}
.ao-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: var(--ao-grid-gap);
}
.ao-card {
aspect-ratio: 1 / 1.15; /* 이미지 정사각 + 이름줄 */
background: var(--ao-card-bg);
border: 1px solid var(--ao-card-border);
border-radius: var(--ao-card-radius);
cursor: pointer;
transition: transform 120ms ease, border-color 120ms ease;
}
.ao-card:hover { transform: translateY(-2px); }
.ao-card.active { border-color: var(--ao-card-border-active); }
.ao-card.placeholder { opacity: 0.55; cursor: pointer; }
```
반응형: 모바일에서는 `grid-template-columns: repeat(2, 1fr)` 또는 `repeat(1, 1fr)`로 축소.
---
## 9. 에러 처리 / Edge Cases
| 상황 | 동작 |
|------|------|
| 이미지 로드 실패 | `<img onError>`로 단색 배경 + 첫 글자 fallback |
| WebSocket 끊김 | TopBar에 disconnected 표시. 카드는 마지막 상태 유지 (회색 처리 안 함 — 기존 동작 유지) |
| `agents[id]` 미존재 | dot 회색(`idle`), 정상 표시 |
| placeholder 클릭 | 우측 패널만 변경, WebSocket 호출/clearNotifications 호출 없음 |
---
## 10. 테스트 계획
- [ ] 6개 이미지 파일이 디렉토리에 존재할 때 그리드 정상 렌더링
- [ ] 이미지 누락 시 fallback 표시
- [ ] WebSocket으로 `agent_state` 수신 시 dot 색상 변경
- [ ] `notification` 수신 시 뱃지 표시, 카드 클릭 시 0으로 리셋
- [ ] active 5명 클릭 → SidePanel 4탭 표시 (기존 동작 유지)
- [ ] placeholder 4슬롯 클릭 → "준비 중" 패널
- [ ] TopBar의 connected/disconnected 표시 정상
- [ ] Canvas 잔재(파일 import 누락 등) 없음 — `npm run build` 통과
- [ ] 모바일 뷰(<768px) 그리드 축소 정상
---
## 11. 이행 절차 (사용자 작업 포함)
1. **사용자**: `src/pages/agent-office/assets/agents/` 디렉토리에 6개 PNG 파일 배치
2. **Claude (구현 단계)**: writing-plans 스킬로 단계별 작업 계획 작성
3. 구현·삭제·테스트 후 commit
4. NAS 배포는 별도 (`npm run release:nas`)
---
## 12. 향후 확장
- 9번째 active 에이전트 채용 시: 이미지 추가 + `AGENT_META` 갱신 + 슬롯 인덱스 매핑 변경
- 그리드 자동 정렬(상태별/우선순위별 sort) — 현재는 정적 배치
- 카드 hover 시 미니 프리뷰 (최근 활동 1줄 요약) — 추후 검토

View File

@@ -0,0 +1,406 @@
# Confidence Signal Pipeline V2 — Phase 4: Signal Generator Design
**작성일**: 2026-05-17
**작성자**: gahusb
**상태**: Approved for implementation
**선행 spec**:
- Phase 0 architecture (`2026-05-15-confidence-signal-pipeline-v2-architecture.md`)
- Phase 1 stock WebAI API (`2026-05-15-signal-v2-phase1-webai-api.md`)
- Phase 2 web-ai pull worker (`2026-05-16-signal-v2-phase2-web-ai-pull-worker.md`)
- Phase 3a KIS data collection (`2026-05-16-signal-v2-phase3a-kis-data-collection.md`)
- Phase 3b Chronos-2 + momentum (`2026-05-16-signal-v2-phase3b-chronos-momentum.md`)
**브레인스토밍 결정 6개**:
- scope = A (신호 생성만, Phase 5 가 발송)
- trigger = A (매 분봉 cycle 후 일괄 평가)
- minute_score = A (Linear 5-level 1.0/0.7/0.5/0.3/0.0)
- 임계값 = A+ (6 env 외부화)
- state.signals schema = A (Phase 0 spec §5.2 그대로)
- 테스트 = A (9 단위 + 1 integration = 10 신규)
---
## 1. 목표
Phase 2/3a/3b 의 모든 산출을 종합해 Phase 0 spec §6.1/§6.2/§6.3 의 매수/매도/dedup 룰 적용. 임계값 통과한 신호를 `state.signals` 에 저장 + `SignalDedup` 으로 24h 중복 차단.
**Why**: Phase 5 (agent-office) 의 입력 계약 완성. signal_v2 가 자체적으로 매수/매도 신호 생성 → Phase 5 가 발송.
---
## 2. 범위
### 포함 (6 항목)
-`signal_generator.py` 신규 — `generate_signals(state, dedup, settings) -> None` (state mutating)
-`config.py` 확장 — 6 env (`STOP_LOSS_PCT`, `TAKE_PROFIT_PCT`, `CHRONOS_SPREAD_THRESHOLD`, `ASKING_BID_RATIO_THRESHOLD`, `CONFIDENCE_THRESHOLD`, `MIN_MOMENTUM_FOR_BUY`)
-`state.py` 확장 — `signals: dict[str, dict]` (Phase 5 input)
-`pull_worker.py` 확장 — 매 cycle 후 `generate_signals` 호출 + signature 확장 (dedup + settings)
-`main.py` 의 lifespan poll_task 호출 시 dedup/settings 전달
- ⑥ 테스트 9 단위 + 1 integration = **10 신규** (45 → 55)
### Phase 4 산출 (Phase 5 input)
`state.signals[ticker]` — Phase 0 spec §5.2 schema:
```python
{
"ticker": str, "name": str,
"action": "buy" | "sell",
"confidence_webai": float,
"current_price": int,
"avg_price": int | None, # sell 시만
"pnl_pct": float | None,
"context": {
"chronos_pred_1d": float (median),
"chronos_pred_conf": float,
"chronos_q10": float, "chronos_q90": float,
"screener_rank": int | None,
"screener_scores": dict | None,
"minute_momentum": str,
"asking_bid_ratio": float,
"news_sentiment": float | None,
"news_reason": str | None,
},
"as_of": str (ISO),
}
```
### 범위 외 (NOT)
- agent-office `/signal` HTTP POST (Phase 5)
- Qwen3 검증 + 이중 텔레그램 (Phase 5)
- 호가 변경 시 즉시 매도 trigger (Phase 7 backlog)
- 자동 매매 (Phase 8 backlog)
- ML 기반 룰 변종 (Phase 7 백테스트 후)
- `kospi_change`, `news_top` 컨텍스트 (Phase 7 backlog)
- 외부 API 호출 — Phase 4 는 state 만 사용 (pure function)
---
## 3. 파일 구조 + 변경 매트릭스
| 파일 | 작업 | 라인 |
|------|------|------|
| `signal_v2/signal_generator.py` | 신규 (generate_signals + 5 helpers) | ~250 |
| `signal_v2/config.py` | Settings 6 field 추가 | +15 |
| `signal_v2/state.py` | PollState `signals` 필드 | +2 |
| `signal_v2/pull_worker.py` | poll_loop signature + 매 cycle 호출 | +10 |
| `signal_v2/main.py` | lifespan poll_task 인자 추가 | +3 |
| `signal_v2/tests/test_signal_generator.py` | 9 단위 신규 | ~350 |
| `signal_v2/tests/test_pull_worker.py` | 1 integration 추가 | +50 |
**합계**: 7 파일 변경, 10 신규 테스트.
### 외부 의존성 신규
**없음**. signal_generator 는 순수 함수, 외부 라이브러리 0.
### 6 신규 env
| env | 기본값 | 의미 |
|-----|--------|------|
| `STOP_LOSS_PCT` | `-0.07` | 손절선 비율. `pnl_pct < 이 값` → 즉시 매도 |
| `TAKE_PROFIT_PCT` | `0.15` | 익절선 비율. `pnl_pct > 이 값` → 검토 알림 |
| `CHRONOS_SPREAD_THRESHOLD` | `0.6` | `(q90-q10)/max(|median|, 0.001) < 이 값` → 매수 통과 |
| `ASKING_BID_RATIO_THRESHOLD` | `0.6` | `bid_ratio >= 이 값` → 매수 통과 |
| `CONFIDENCE_THRESHOLD` | `0.7` | `confidence_webai > 이 값` → 신호 발생 |
| `MIN_MOMENTUM_FOR_BUY` | `strong_up` | 분봉 모멘텀 카테고리 |
---
## 4. 매수 룰 + Confidence
### 4.1 매수 룰 대상
- screener Top-N (`state.screener_preview.items`)
- portfolio 보유 종목 (추가 매수 검토, dedup 으로 중복 차단)
### 4.2 Hard gate (모든 조건 충족)
1. `state.chronos_predictions[ticker].median > 0` (다음날 상승)
2. `(q90 - q10) < settings.chronos_spread_threshold` (**absolute spread** — Phase 3b 실 운영 데이터 기반 변경)
3. `state.minute_momentum[ticker] == settings.min_momentum_for_buy` (기본 strong_up)
4. `state.asking_price[ticker].bid_ratio >= settings.asking_bid_ratio_threshold`
**Spread formula 결정 노트 (2026-05-17 implementer 변경 채택)**:
- Phase 0 spec §6.1 의 한국어 "(90-10 분위수) / 50 분위수 < 0.6" 은 *relative spread* 로 명시되었으나, Phase 3b 실 운영 결과 (Chronos zero-shot prediction 의 median 이 종종 0 가까이) 에서 relative formula 가 거의 모든 신호 거부 → useless.
- **변경**: absolute spread `(q90 - q10) < 0.6` 사용. 0.6 = 60% 변동 예측 — 한국 주식 1-day 변동성 (1-2%) 대비 매우 넓음 (모델 자신 없음 신호).
- 결과: Phase 3b smoke 005930 (median=-0.59%, q10=-8.9%, q90=6.4%, spread=15.3%) → spread 0.153 < 0.6 → hard gate 통과 가능 (다른 조건 충족 시).
- Phase 7 IC 검증 시 임계값 재조정 가능 (env `CHRONOS_SPREAD_THRESHOLD`).
### 4.3 Soft confidence (Phase 0 spec §6.1)
```python
chronos_conf = state.chronos_predictions[ticker]["conf"]
minute_score = MOMENTUM_SCORES[state.minute_momentum[ticker]]
# MOMENTUM_SCORES = {"strong_up": 1.0, "weak_up": 0.7, "neutral": 0.5,
# "weak_down": 0.3, "strong_down": 0.0}
screener_norm = 1 - (rank - 1) / 20 if rank is not None else 0.0
confidence_webai = chronos_conf * 0.5 + minute_score * 0.3 + screener_norm * 0.2
```
### 4.4 임계값
`confidence_webai > settings.confidence_threshold` (기본 0.7) → 신호 발생.
### 4.5 누락 처리
- portfolio (Top-N 외) 매수: `screener_rank = None``screener_norm = 0` (보수적)
- `chronos_predictions[ticker]` 누락 → silent (Hard gate 위반)
- `asking_price[ticker]` 누락 → silent
---
## 5. 매도 룰 + Dedup
### 5.1 매도 대상
portfolio holdings 만 (`state.portfolio.holdings`).
### 5.2 매도 룰 (Phase 0 spec §6.2)
**(a) 손절선 (즉시 trigger)**:
- `pnl_pct < settings.stop_loss_pct` (기본 -0.07)
- 다른 룰 무관 — 즉시 매도
- `confidence_webai = 1.0`
**(b) 익절선 (검토 알림)**:
- `pnl_pct > settings.take_profit_pct` (기본 0.15)
- "검토 권고" — 강제 매도 X
- `confidence_webai = 0.6`
**(c) 이상 신호**:
- `chronos_predictions[ticker].median < -0.01`
- `minute_momentum[ticker] == "strong_down"`
- `asking_price[ticker].bid_ratio < (1 - settings.asking_bid_ratio_threshold)` (매도세 ≥ 60%)
- confidence_webai = chronos_conf × 0.5 + inverted_minute × 0.3 + 1.0 × 0.2
- 임계값 > `settings.confidence_threshold`
### 5.3 우선순위 (같은 ticker 다중 trigger 시)
1. **손절** (Phase 0 spec §6.2 "즉시") — 다른 룰 우회
2. **이상 신호**
3. **익절선**
상위 trigger 시 하위 skip (한 종목당 한 cycle 1 매도 신호).
### 5.4 Dedup (Phase 0 spec §6.3 + Phase 2 SignalDedup)
```python
if dedup.is_recent(ticker, action, within_hours=24):
continue # silent
# 신호 dict 생성
state.signals[ticker] = {...}
dedup.record(ticker, action, confidence=confidence_webai)
```
Dedup 키 `(ticker, action)` — 같은 종목의 매수/매도 별도 추적, 충돌 없음.
손절선도 dedup 적용 (Phase 0 spec §6.3 "1일 1회 max").
---
## 6. State 통합 + pull_worker
### 6.1 PollState 확장
```python
signals: dict[str, dict] = field(default_factory=dict)
```
매 cycle 마다 **덮어쓰기 X** — 같은 ticker key 재발생 시 갱신, 그 외 유지. dedup 으로 중복 차단되므로 누적 안전. Phase 5 consumer 가 처리 후 본인 측 dedup.
### 6.2 pull_worker 흐름
```python
async def poll_loop(client, state, shutdown,
kis_client=None, chronos=None,
dedup=None, settings=None) -> None:
while not shutdown.is_set():
now = datetime.now(KST)
if _is_market_day(now) and _is_polling_window(now):
# 1. stock + KIS 분봉/호가 (Phase 2 + 3a)
await _run_polling_cycle(client, state, kis_client=kis_client)
# 2. 분봉 모멘텀 (Phase 3b)
update_minute_momentum_for_all(state)
# 3. 종가 트리거 시 Chronos (Phase 3b)
if _is_post_close_trigger(now) and chronos and kis_client:
await _run_post_close_cycle(kis_client, chronos, state)
# 4. (신규 Phase 4) 신호 생성
if dedup is not None and settings is not None:
try:
generate_signals(state, dedup, settings)
except Exception:
logger.exception("generate_signals failed")
...
```
### 6.3 main.py lifespan
```python
_ctx.poll_task = asyncio.create_task(
poll_loop(
_ctx.client, state_mod.state, _ctx.shutdown,
kis_client=_ctx.kis_client,
chronos=_ctx.chronos,
dedup=_ctx.dedup,
settings=settings,
)
)
```
---
## 7. signal_generator.py 구조
```python
def generate_signals(state: PollState, dedup: SignalDedup, settings: Settings) -> None:
"""Phase 4 entry point — state mutating."""
_evaluate_buy_signals(state, dedup, settings)
_evaluate_sell_signals(state, dedup, settings)
def _evaluate_buy_signals(state, dedup, settings) -> None:
"""screener Top-N + portfolio 매수 후보 평가."""
candidates = _buy_candidates(state) # screener Top-N + portfolio holdings
for ticker, rank in candidates:
if not _check_buy_hard_gate(state, ticker, settings):
continue
confidence = _compute_buy_confidence(state, ticker, rank)
if confidence <= settings.confidence_threshold:
continue
if dedup.is_recent(ticker, "buy", within_hours=24):
continue
state.signals[ticker] = _build_buy_signal(state, ticker, rank, confidence)
dedup.record(ticker, "buy", confidence=confidence)
def _evaluate_sell_signals(state, dedup, settings) -> None:
"""portfolio 보유 종목 매도 평가 — 손절 > 이상 > 익절 우선순위."""
if state.portfolio is None:
return
for holding in state.portfolio.get("holdings", []):
ticker = holding["ticker"]
# 우선순위 1: 손절선
sell = _try_stop_loss(state, holding, settings)
# 우선순위 2: 이상 신호
if sell is None:
sell = _try_anomaly(state, holding, settings)
# 우선순위 3: 익절선
if sell is None:
sell = _try_take_profit(state, holding, settings)
if sell is None:
continue
if dedup.is_recent(ticker, "sell", within_hours=24):
continue
state.signals[ticker] = sell
dedup.record(ticker, "sell", confidence=sell["confidence_webai"])
```
Helper 함수:
- `_buy_candidates(state) -> list[tuple[ticker, rank | None]]`
- `_check_buy_hard_gate(state, ticker, settings) -> bool`
- `_compute_buy_confidence(state, ticker, rank | None) -> float`
- `_build_buy_signal(state, ticker, rank, confidence) -> dict`
- `_try_stop_loss(state, holding, settings) -> dict | None`
- `_try_anomaly(state, holding, settings) -> dict | None`
- `_try_take_profit(state, holding, settings) -> dict | None`
- `_build_context(state, ticker, rank, ...) -> dict`
---
## 8. 테스트 (10 신규)
### 8.1 `test_signal_generator.py` (9 단위)
| # | 이름 | Setup | 검증 |
|---|------|-------|------|
| 1 | `test_buy_signal_when_all_conditions_pass_and_confidence_high` | chronos +2%, narrow, strong_up, bid_ratio 0.7, rank 1 | state.signals[ticker]["action"]=="buy", confidence > 0.7, dedup.record 호출 |
| 2 | `test_silent_when_chronos_median_negative` | median -1% | state.signals empty |
| 3 | `test_silent_when_distribution_spread_too_wide` | spread 1.0 | empty |
| 4 | `test_silent_when_momentum_not_strong_up` | weak_up | empty |
| 5 | `test_silent_when_bid_ratio_below_threshold` | 0.5 | empty |
| 6 | `test_silent_when_confidence_below_threshold` | rank 20 + median +0.5% (chronos_conf 낮음) → confidence < 0.7 | empty |
| 7 | `test_sell_signal_when_stop_loss_triggered` | pnl_pct -0.08 | "sell" + confidence 1.0 |
| 8 | `test_sell_signal_when_take_profit_triggered` | pnl_pct 0.16 | "sell" + confidence 0.6 |
| 9 | `test_silent_when_dedup_recently_sent` | dedup.is_recent True | empty |
### 8.2 `test_pull_worker.py` (1 integration)
| # | 이름 | 검증 |
|---|------|------|
| 10 | `test_poll_loop_calls_generate_signals_after_cycle` | mock state setup + mock dedup → poll_loop 1 cycle → state.signals 갱신 |
**합계**: 9 + 1 = **10 신규**. 45 → 55 total.
---
## 9. 위험 / 운영 / DoD
### 9.1 위험 매트릭스
| 위험 | 완화 |
|------|------|
| Phase 0 spec 의 confidence 공식이 실 운영과 안 맞음 | 6 env 외부화 → Phase 7 IC 검증 후 .env 조정 |
| Chronos 누락 (장 시작 첫 cycle) | Hard gate 위반 → silent. 종가 cron 후 매수 신호 가능 |
| Dedup DB 손상 | WAL + busy_timeout. 운영자 manual 복구 (signal_v2.db 삭제) |
| 동시 cycle 에서 같은 종목 buy + sell trigger | dedup PK `(ticker, action)` 별도 추적 — 충돌 없음 |
| portfolio 매수 → screener_norm=0 → 신호 발생 어려움 | 보수적. 다른 component 높아야 신호. 의도된 동작 |
| 손절선 trigger 후 24h 추가 손실 → 다음 알림 차단 | 운영적 허용 (Phase 0 spec §6.3 1일 1회 max) |
| 신호 빈도 너무 적음 | 4주 IC 검증 + 임계값 완화 |
| 신호 빈도 너무 많음 (false positive) | dedup + 임계값 강화. Phase 7 |
| 매도 우선순위 잘못 (손절 > 이상 > 익절) | 테스트 케이스로 검증 + 코드 명시 |
| signals dict 누적 (cycle 사이 stale entry) | dedup 으로 중복 차단되므로 안전. Phase 5 consumer 가 처리 후 본인 측 marker |
### 9.2 운영 영향
| 항목 | 영향 |
|------|------|
| 다운타임 | signal_v2 재기동 ~5초 |
| 사용자 영향 | 없음 (Phase 5 까지 발송 없음) |
| `.env` 갱신 | optional 0-6개 (기본값 충분) |
| V1 영향 | 0 |
| KIS API 부하 | 0 (Phase 4 는 외부 호출 없음) |
### 9.3 Phase 4 완료 조건 (DoD)
- [ ] `signal_v2/signal_generator.py` 신규 (generate_signals + 8 helpers)
- [ ] `signal_v2/config.py` Settings 에 6 field 추가 (default 있음)
- [ ] `signal_v2/state.py` PollState `signals` field
- [ ] `signal_v2/pull_worker.py` poll_loop signature + 매 cycle 호출
- [ ] `signal_v2/main.py` lifespan 의 poll_task 인자 (dedup, settings) 추가
- [ ] 9 단위 + 1 integration 테스트 PASS (총 55)
- [ ] 운영 smoke: signal_v2 시작 → 1 cycle 후 state.signals 빈 dict (운영 시간대 신호 발생 가능 종목 없을 시 정상) 또는 ≥ 1 신호 생성
- [ ] V1 무영향
- [ ] git push
---
## 10. Phase 5 와의 관계
본 Phase 4 완료 후 즉시 **Phase 5 (agent-office /signal + Qwen3 + 이중 텔레그램)** brainstorming. 의존성:
```
[Phase 4 spec/plan/실행] → [Phase 5 spec/plan/실행]
3-5일 2주
```
Phase 5 의 입력 = 본 spec 의 `state.signals[ticker]` (state polling 또는 HTTP push). Phase 5 작업:
- agent-office `/signal` endpoint 신설 (Phase 0 spec §5.2 schema 수신)
- web-ai → agent-office HTTP client 추가 (signal_v2 측)
- web-ai 의 Ollama Qwen3 14B Q4 설치 + agent-office 의 LLM 검증 호출
- 이중 텔레그램 (본인 풀 / 아내 lite)
---
## 11. Backlog (본 spec NOT)
- 호가 변경 시 즉시 매도 trigger — Phase 7 운영 후 검토
- `kospi_change` 컨텍스트 (KIS 지수 fetch) — Phase 7
- `news_top` 컨텍스트 (news_sentiment.reason 다중 추출) — Phase 7
- 매수/매도 ML 룰 — Phase 7 백테스트 후
- portfolio 매수의 screener_norm fallback (다른 default 값) — IC 검증 후
- 신호 hit-rate 대시보드 — Phase 7
- 분할 매수/매도 전략 — Phase 7 이후
- 자동 매매 (실주문) — Phase 8
- 손절선 dedup 면제 (즉시성 위해) — Phase 7 운영 검증 후

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

View File

@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 300" width="200" height="300">
<defs>
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1a0d2e"/>
<stop offset="100%" stop-color="#0a0420"/>
</linearGradient>
<linearGradient id="goldFrame" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#d4af37"/>
<stop offset="100%" stop-color="#8b6914"/>
</linearGradient>
</defs>
<rect width="200" height="300" rx="14" fill="url(#bg)"/>
<rect x="8" y="8" width="184" height="284" rx="10" fill="none"
stroke="url(#goldFrame)" stroke-width="2"/>
<g transform="translate(100 150)" fill="#d4af37" font-family="serif" text-anchor="middle">
<circle r="38" fill="none" stroke="#d4af37" stroke-width="1.5"/>
<text font-size="48" dy="14" font-style="italic">A</text>
<g opacity=".5">
<circle cx="-60" cy="-90" r="1.5"/>
<circle cx="55" cy="-100" r="1"/>
<circle cx="-50" cy="80" r="1.2"/>
<circle cx="65" cy="90" r="1"/>
<circle cx="0" cy="-110" r="1.6"/>
</g>
</g>
<text x="100" y="280" fill="#d4af37" font-family="serif" font-size="10"
text-anchor="middle" letter-spacing="2">ARCANA TAROT</text>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

View File

@@ -55,6 +55,22 @@ export async function apiPut(path, body) {
return res.json(); return res.json();
} }
export async function apiPatch(path, body) {
const res = await fetch(toApiUrl(path), {
method: "PATCH",
headers: {
"Accept": "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
export function getLatest() { export function getLatest() {
return apiGet("/api/lotto/latest"); return apiGet("/api/lotto/latest");
} }
@@ -681,3 +697,75 @@ export const refreshScreenerSnap = () => apiPost('/api/stock/screener
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`); export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`); export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
// --- Lotto Weight Evolver ---
export async function fetchEvolverStatus() {
const r = await fetch('/api/lotto/evolver/status');
if (!r.ok) throw new Error(`evolver/status ${r.status}`);
return r.json();
}
export async function fetchEvolverHistory(weeks = 12) {
const r = await fetch(`/api/lotto/evolver/history?weeks=${weeks}`);
if (!r.ok) throw new Error(`evolver/history ${r.status}`);
return r.json();
}
export async function fetchLottoTasks({ days = 7, taskType = null } = {}) {
const params = new URLSearchParams({ days: String(days), limit: '100' });
if (taskType) params.set('task_type', taskType);
const r = await fetch(`/api/agent-office/agents/lotto/tasks?${params}`);
if (!r.ok) throw new Error(`agent-office tasks ${r.status}`);
return r.json();
}
export async function fetchLottoLogs({ days = 7 } = {}) {
const r = await fetch(`/api/agent-office/agents/lotto/logs?limit=200`);
if (!r.ok) throw new Error(`agent-office logs ${r.status}`);
const data = await r.json();
if (!days) return data;
const cutoff = new Date(Date.now() - days * 24 * 3600 * 1000).toISOString();
return { items: (data.items || data.logs || []).filter(l => (l.created_at || '') >= cutoff) };
}
export async function triggerEvolverGenerate() {
const r = await fetch('/api/lotto/evolver/generate-now', { method: 'POST' });
if (!r.ok) throw new Error(`generate-now ${r.status}`);
return r.json();
}
export async function triggerEvolverEvaluate() {
const r = await fetch('/api/lotto/evolver/evaluate-now', { method: 'POST' });
if (!r.ok) throw new Error(`evaluate-now ${r.status}`);
return r.json();
}
// --- Tarot Lab ---
export function tarotInterpret(body) {
return apiPost('/api/agent-office/tarot/interpret', body);
}
export function tarotSaveReading(body) {
return apiPost('/api/agent-office/tarot/readings', body);
}
export function tarotListReadings({ page = 1, size = 20, favorite, spread_type, category } = {}) {
const qs = new URLSearchParams({ page: String(page), size: String(size) });
if (favorite !== undefined) qs.set('favorite', favorite ? 'true' : 'false');
if (spread_type) qs.set('spread_type', spread_type);
if (category) qs.set('category', category);
return apiGet(`/api/agent-office/tarot/readings?${qs.toString()}`);
}
export function tarotGetReading(id) {
return apiGet(`/api/agent-office/tarot/readings/${id}`);
}
export function tarotPatchReading(id, body) {
return apiPatch(`/api/agent-office/tarot/readings/${id}`, body);
}
export function tarotDeleteReading(id) {
return apiDelete(`/api/agent-office/tarot/readings/${id}`);
}

View File

@@ -134,3 +134,12 @@ export const IconInsta = () =>
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" strokeWidth="0" /> <circle cx="17.5" cy="6.5" r="1" fill="currentColor" strokeWidth="0" />
</> </>
); );
export const IconTarot = () =>
svg(
<>
<rect x="5" y="3" width="14" height="18" rx="2" />
<path d="M12 7v10M9 12h6" />
<circle cx="12" cy="12" r="3" />
</>
);

View File

@@ -5,8 +5,8 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100vh;
background: #0d0d1a; background: #0f172a;
color: #ffffff; color: #e2e8f0;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
overflow: hidden; overflow: hidden;
} }
@@ -32,50 +32,9 @@
font-size: 15px; font-size: 15px;
color: #8b5cf6; color: #8b5cf6;
} }
.ao-topbar-status { .ao-topbar-status { font-size: 11px; }
font-size: 11px;
}
.ao-topbar-status.connected { color: #22c55e; } .ao-topbar-status.connected { color: #22c55e; }
.ao-topbar-status.disconnected { color: #ef4444; } .ao-topbar-status.disconnected { color: #ef4444; }
.ao-topbar-right {
display: flex;
align-items: center;
gap: 10px;
}
.ao-topbar-select {
background: #2a2a3e;
color: #aaa;
border: 1px solid #444;
padding: 3px 8px;
border-radius: 4px;
font-size: 12px;
font-family: inherit;
}
.ao-topbar-zoom {
display: flex;
align-items: center;
gap: 4px;
}
.ao-topbar-zoom button {
background: #2a2a3e;
color: #aaa;
border: 1px solid #444;
width: 24px;
height: 24px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.ao-topbar-zoom button:disabled {
opacity: 0.3;
cursor: default;
}
.ao-topbar-zoom span {
color: #888;
font-size: 12px;
min-width: 28px;
text-align: center;
}
/* ===== Main Area ===== */ /* ===== Main Area ===== */
.ao-main { .ao-main {
@@ -84,13 +43,103 @@
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.ao-canvas {
/* ===== Grid Wrap ===== */
.ao-grid-wrap {
flex: 1; flex: 1;
cursor: grab; overflow-y: auto;
padding: 24px;
}
.ao-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
width: 100%;
}
/* ===== Agent Card ===== */
.ao-card {
position: relative;
background: #1e293b;
border: 1px solid #334155;
border-radius: 12px;
cursor: pointer;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
font-family: inherit;
color: inherit;
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
}
.ao-card:hover {
transform: translateY(-2px);
border-color: var(--card-accent, #60a5fa);
}
.ao-card.active {
border-color: var(--card-accent, #60a5fa);
box-shadow: 0 0 0 2px var(--card-accent, #60a5fa);
}
.ao-card.placeholder {
opacity: 0.55;
}
.ao-card-dot {
position: absolute;
top: 8px;
left: 8px;
width: 10px;
height: 10px;
border-radius: 50%;
background: #6b7280;
box-shadow: 0 0 0 2px #0f172a;
}
.ao-card-dot.working { background: #22c55e; }
.ao-card-dot.error { background: #ef4444; }
.ao-card-dot.waiting_approval { background: #f59e0b; }
.ao-card-dot.pulse {
animation: ao-pulse 1.6s ease-in-out infinite;
}
@keyframes ao-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.45; transform: scale(1.2); }
}
.ao-card-badge {
position: absolute;
top: 6px;
right: 6px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: #ef4444;
color: #fff;
border-radius: 9px;
font-size: 10px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
}
.ao-card-image {
width: 100%;
aspect-ratio: 941 / 1672;
border-radius: 8px;
overflow: hidden;
background: #0f172a;
margin-bottom: 8px;
}
.ao-card-image img {
width: 100%;
height: 100%;
object-fit: contain;
display: block; display: block;
} }
.ao-canvas:active { .ao-card-name {
cursor: grabbing; font-size: 12px;
color: #e2e8f0;
text-align: center;
} }
/* ===== Side Panel ===== */ /* ===== Side Panel ===== */
@@ -103,6 +152,11 @@
flex-shrink: 0; flex-shrink: 0;
animation: slideIn 0.2s ease-out; animation: slideIn 0.2s ease-out;
} }
.ao-sidepanel-initial {
display: flex;
align-items: center;
justify-content: center;
}
@keyframes slideIn { @keyframes slideIn {
from { transform: translateX(100%); } from { transform: translateX(100%); }
to { transform: translateX(0); } to { transform: translateX(0); }
@@ -120,13 +174,18 @@
gap: 10px; gap: 10px;
} }
.ao-sidepanel-icon { .ao-sidepanel-icon {
width: 36px; width: 40px;
height: 36px; height: 40px;
border-radius: 8px; border-radius: 8px;
display: flex; border: 2px solid #444;
align-items: center; overflow: hidden;
justify-content: center; flex-shrink: 0;
font-size: 18px; }
.ao-sidepanel-icon img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
} }
.ao-sidepanel-name { .ao-sidepanel-name {
font-weight: bold; font-weight: bold;
@@ -134,7 +193,12 @@
} }
.ao-sidepanel-state { .ao-sidepanel-state {
font-size: 11px; font-size: 11px;
color: #22c55e; color: #94a3b8;
}
.ao-sidepanel-actions {
display: flex;
align-items: center;
gap: 4px;
} }
.ao-sidepanel-close { .ao-sidepanel-close {
background: none; background: none;
@@ -144,9 +208,19 @@
cursor: pointer; cursor: pointer;
padding: 0 4px; padding: 0 4px;
} }
.ao-sidepanel-close:hover { .ao-sidepanel-close:hover { color: #fff; }
color: #fff; /* 전체 화면 토글 — 모바일 전용 (데스크톱에서는 숨김) */
.ao-sidepanel-expand {
display: none;
background: none;
border: none;
color: #888;
font-size: 18px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
} }
.ao-sidepanel-expand:hover { color: #fff; }
/* Tabs */ /* Tabs */
.ao-sidepanel-tabs { .ao-sidepanel-tabs {
@@ -170,9 +244,7 @@
border-bottom-color: #8b5cf6; border-bottom-color: #8b5cf6;
font-weight: bold; font-weight: bold;
} }
.ao-sidepanel-tab:hover { .ao-sidepanel-tab:hover { color: #aaa; }
color: #aaa;
}
.ao-sidepanel-content { .ao-sidepanel-content {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
@@ -207,10 +279,7 @@
.ao-btn-quick:hover { background: #3a3a5e; } .ao-btn-quick:hover { background: #3a3a5e; }
.ao-btn-quick:disabled { opacity: 0.4; } .ao-btn-quick:disabled { opacity: 0.4; }
.ao-param-row { .ao-param-row { display: flex; gap: 6px; }
display: flex;
gap: 6px;
}
.ao-input { .ao-input {
flex: 1; flex: 1;
background: #1a1a2e; background: #1a1a2e;
@@ -236,177 +305,67 @@
.ao-btn-send:hover { background: #5b21b6; } .ao-btn-send:hover { background: #5b21b6; }
.ao-btn-send:disabled { opacity: 0.4; } .ao-btn-send:disabled { opacity: 0.4; }
/* Approval */
.ao-approval-card { .ao-approval-card {
background: rgba(146,64,14,0.15); background: rgba(146,64,14,0.15);
border: 1px solid #92400e; border: 1px solid #92400e;
border-radius: 6px; border-radius: 6px;
padding: 10px; padding: 10px;
} }
.ao-approval-title { .ao-approval-title { color: #fbbf24; font-size: 12px; font-weight: bold; margin-bottom: 4px; }
color: #fbbf24; .ao-approval-desc { color: #ddd; font-size: 11px; margin-bottom: 8px; word-break: break-all; }
font-size: 12px; .ao-approval-actions { display: flex; gap: 6px; }
font-weight: bold;
margin-bottom: 4px;
}
.ao-approval-desc {
color: #ddd;
font-size: 11px;
margin-bottom: 8px;
word-break: break-all;
}
.ao-approval-actions {
display: flex;
gap: 6px;
}
.ao-btn-approve { .ao-btn-approve {
flex: 1; flex: 1; background: #065f46; color: #fff; border: none;
background: #065f46; padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
color: #fff;
border: none;
padding: 7px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
} }
.ao-btn-reject { .ao-btn-reject {
flex: 1; flex: 1; background: #7f1d1d; color: #fff; border: none;
background: #7f1d1d; padding: 7px; border-radius: 4px; font-size: 12px; cursor: pointer;
color: #fff;
border: none;
padding: 7px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
} }
/* ===== Task Tab ===== */ /* ===== Task Tab ===== */
.ao-task-tab { display: flex; flex-direction: column; gap: 4px; } .ao-task-tab { display: flex; flex-direction: column; gap: 4px; }
.ao-task-item { .ao-task-item { background: #1a1a2e; border-radius: 4px; padding: 8px; cursor: pointer; }
background: #1a1a2e;
border-radius: 4px;
padding: 8px;
cursor: pointer;
}
.ao-task-item:hover { background: #222240; } .ao-task-item:hover { background: #222240; }
.ao-task-header { .ao-task-header { display: flex; align-items: center; gap: 6px; font-size: 12px; }
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.ao-task-type { color: #ccc; font-weight: bold; flex: 1; } .ao-task-type { color: #ccc; font-weight: bold; flex: 1; }
.ao-task-badge { .ao-task-badge { padding: 1px 6px; border-radius: 3px; font-size: 10px; }
padding: 1px 6px;
border-radius: 3px;
font-size: 10px;
}
.ao-task-time { color: #666; font-size: 10px; } .ao-task-time { color: #666; font-size: 10px; }
.ao-task-result { .ao-task-result {
margin-top: 6px; margin-top: 6px; background: #0d0d1a; padding: 6px; border-radius: 3px;
background: #0d0d1a; font-size: 10px; color: #aaa; max-height: 200px; overflow-y: auto;
padding: 6px; white-space: pre-wrap; word-break: break-all;
border-radius: 3px;
font-size: 10px;
color: #aaa;
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
} }
/* ===== Token Tab ===== */ /* ===== Token Tab ===== */
.ao-token-tab { display: flex; flex-direction: column; gap: 12px; } .ao-token-tab { display: flex; flex-direction: column; gap: 12px; }
.ao-token-period { .ao-token-period { display: flex; gap: 4px; }
display: flex;
gap: 4px;
}
.ao-btn-period { .ao-btn-period {
flex: 1; flex: 1; background: #1a1a2e; color: #888; border: 1px solid #333;
background: #1a1a2e; padding: 5px; border-radius: 4px; font-size: 11px; font-family: inherit; cursor: pointer;
color: #888;
border: 1px solid #333;
padding: 5px;
border-radius: 4px;
font-size: 11px;
font-family: inherit;
cursor: pointer;
}
.ao-btn-period.active {
background: #4c1d95;
color: #fff;
border-color: #4c1d95;
}
.ao-token-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.ao-token-card {
background: #1a1a2e;
border-radius: 6px;
padding: 10px;
text-align: center;
}
.ao-token-label {
font-size: 10px;
color: #888;
text-transform: uppercase;
margin-bottom: 4px;
}
.ao-token-value {
font-size: 18px;
font-weight: bold;
color: #fff;
} }
.ao-btn-period.active { background: #4c1d95; color: #fff; border-color: #4c1d95; }
.ao-token-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.ao-token-card { background: #1a1a2e; border-radius: 6px; padding: 10px; text-align: center; }
.ao-token-label { font-size: 10px; color: #888; text-transform: uppercase; margin-bottom: 4px; }
.ao-token-value { font-size: 18px; font-weight: bold; color: #fff; }
.ao-token-bar { margin-top: 4px; } .ao-token-bar { margin-top: 4px; }
.ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; } .ao-token-bar-label { font-size: 10px; color: #888; margin-bottom: 4px; }
.ao-token-bar-track { .ao-token-bar-track { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: #1a1a2e; }
display: flex;
height: 8px;
border-radius: 4px;
overflow: hidden;
background: #1a1a2e;
}
.ao-token-bar-fill.input { background: #3b82f6; } .ao-token-bar-fill.input { background: #3b82f6; }
.ao-token-bar-fill.output { background: #8b5cf6; } .ao-token-bar-fill.output { background: #8b5cf6; }
.ao-token-bar-legend { .ao-token-bar-legend { display: flex; gap: 12px; font-size: 10px; color: #888; margin-top: 4px; }
display: flex; .ao-token-bar-legend .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
gap: 12px;
font-size: 10px;
color: #888;
margin-top: 4px;
}
.ao-token-bar-legend .dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 4px;
}
.ao-token-bar-legend .dot.input { background: #3b82f6; } .ao-token-bar-legend .dot.input { background: #3b82f6; }
.ao-token-bar-legend .dot.output { background: #8b5cf6; } .ao-token-bar-legend .dot.output { background: #8b5cf6; }
.ao-token-detail { .ao-token-detail { display: flex; justify-content: space-between; font-size: 10px; color: #666; }
display: flex;
justify-content: space-between;
font-size: 10px;
color: #666;
}
/* ===== Log Tab ===== */ /* ===== Log Tab ===== */
.ao-log-tab { .ao-log-tab {
max-height: 100%; max-height: 100%; overflow-y: auto; display: flex; flex-direction: column; gap: 2px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 2px;
} }
.ao-log-item { .ao-log-item {
display: flex; display: flex; gap: 6px; font-size: 11px; padding: 3px 0; border-bottom: 1px solid #1a1a2e;
gap: 6px;
font-size: 11px;
padding: 3px 0;
border-bottom: 1px solid #1a1a2e;
} }
.ao-log-time { color: #555; min-width: 60px; } .ao-log-time { color: #555; min-width: 60px; }
.ao-log-level { min-width: 48px; font-weight: bold; } .ao-log-level { min-width: 48px; font-weight: bold; }
@@ -414,47 +373,53 @@
/* ===== Common ===== */ /* ===== Common ===== */
.ao-empty { .ao-empty {
color: #555; color: #94a3b8;
text-align: center; text-align: center;
padding: 24px; padding: 24px;
font-size: 13px; font-size: 13px;
line-height: 1.6;
} }
/* ===== Mobile (< 768px) ===== */ /* ===== Mobile (< 768px) ===== */
@media (max-width: 768px) { @media (max-width: 768px) {
.ao-topbar-right { gap: 6px; } .ao-grid-wrap { padding: 12px; }
.ao-topbar-select { font-size: 11px; padding: 2px 6px; } .ao-grid {
grid-template-columns: repeat(2, 1fr);
.ao-main { gap: 10px;
flex-direction: column;
} }
.ao-main { flex-direction: column; }
.ao-canvas {
flex: 1;
}
/* Side panel → bottom sheet */
.ao-sidepanel { .ao-sidepanel {
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
top: auto;
width: 100%; width: 100%;
height: 55vh;
max-height: 55vh; max-height: 55vh;
border-left: none; border-left: none;
border-top: 1px solid #333; border-top: 1px solid #333;
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
animation: slideUp 0.25s ease-out; animation: slideUp 0.25s ease-out;
z-index: 100; z-index: 100;
transition: height 0.25s ease, max-height 0.25s ease, border-radius 0.25s ease;
}
/* 전체 화면으로 확장 */
.ao-sidepanel.expanded {
top: 0;
height: 100dvh;
max-height: 100dvh;
border-radius: 0;
border-top: none;
} }
@keyframes slideUp { @keyframes slideUp {
from { transform: translateY(100%); } from { transform: translateY(100%); }
to { transform: translateY(0); } to { transform: translateY(0); }
} }
.ao-sidepanel-header { .ao-sidepanel-expand { display: inline-block; }
padding: 8px 12px; .ao-sidepanel-header { padding: 8px 12px; }
}
.ao-sidepanel-header::before { .ao-sidepanel-header::before {
content: ''; content: '';
display: block; display: block;
@@ -464,12 +429,7 @@
border-radius: 2px; border-radius: 2px;
margin: 0 auto 8px; margin: 0 auto 8px;
} }
.ao-sidepanel-tab { font-size: 11px; padding: 6px 2px; }
.ao-sidepanel-tab {
font-size: 11px;
padding: 6px 2px;
}
.ao-sidepanel-content { .ao-sidepanel-content {
padding: 8px 12px; padding: 8px 12px;
padding-bottom: env(safe-area-inset-bottom, 16px); padding-bottom: env(safe-area-inset-bottom, 16px);

View File

@@ -1,96 +1,70 @@
// src/pages/agent-office/AgentOffice.jsx // src/pages/agent-office/AgentOffice.jsx
import { useState, useEffect, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useAgentManager } from './hooks/useAgentManager.js'; import { useAgentManager } from './hooks/useAgentManager.js';
import { useOfficeCanvas } from './hooks/useOfficeCanvas.js'; import { AGENT_META } from './constants.js';
import TopBar from './components/TopBar.jsx'; import TopBar from './components/TopBar.jsx';
import AgentGrid from './components/AgentGrid.jsx';
import SidePanel from './components/SidePanel.jsx'; import SidePanel from './components/SidePanel.jsx';
import EmptyDetailPanel from './components/EmptyDetailPanel.jsx';
import './AgentOffice.css'; import './AgentOffice.css';
export default function AgentOffice() { export default function AgentOffice() {
const { const {
agents, pendingTasks, notifications, connected, agents, pendingTasks, notifications, connected, reconnectAttempt,
refreshTrigger, clearNotifications refreshTrigger, clearNotifications
} = useAgentManager(); } = useAgentManager();
const { // selectedAgent: null | active agent id | "placeholder-N"
canvasRef, updateAgentState, setAgentNotification,
setTheme, setZoom, hitTest, getZoom, wasDragging
} = useOfficeCanvas();
const [selectedAgent, setSelectedAgent] = useState(null); const [selectedAgent, setSelectedAgent] = useState(null);
const [theme, setThemeState] = useState(localStorage.getItem('agent-office-theme') || 'modern');
const [zoom, setZoomState] = useState(2);
// WebSocket 상태 → 캔버스 동기화 const handleSelectAgent = useCallback((agentId) => {
useEffect(() => { setSelectedAgent(agentId);
for (const [id, agentState] of Object.entries(agents)) { clearNotifications(agentId);
updateAgentState(id, agentState.state, agentState.detail); }, [clearNotifications]);
}
}, [agents, updateAgentState]);
// 알림 → 캔버스 동기화 const handleSelectPlaceholder = useCallback((placeholderKey) => {
useEffect(() => { setSelectedAgent(placeholderKey);
for (const [id, count] of Object.entries(notifications)) { }, []);
setAgentNotification(id, count);
}
}, [notifications, setAgentNotification]);
// 캔버스 클릭 핸들러 const handleClose = useCallback(() => {
const handleCanvasClick = useCallback((e) => {
if (wasDragging()) return; // 드래그 후 발생하는 클릭 무시
const result = hitTest(e.clientX, e.clientY);
if (result.type === 'agent') {
setSelectedAgent(result.id);
clearNotifications(result.id);
setAgentNotification(result.id, 0);
} else {
setSelectedAgent(null); setSelectedAgent(null);
} }, []);
}, [hitTest, clearNotifications, setAgentNotification, wasDragging]);
// 테마 변경 const pendingTask = selectedAgent && AGENT_META[selectedAgent]
const handleThemeChange = useCallback((name) => {
setThemeState(name);
setTheme(name);
}, [setTheme]);
// 줌 변경
const handleZoomChange = useCallback((level) => {
setZoomState(level);
setZoom(level);
}, [setZoom]);
// 선택된 에이전트의 pending task
const pendingTask = selectedAgent
? pendingTasks.find(t => t.agent_id === selectedAgent) ? pendingTasks.find(t => t.agent_id === selectedAgent)
: null; : null;
return ( let rightPanel;
<div className="ao-root"> if (selectedAgent === null) {
<TopBar rightPanel = <EmptyDetailPanel variant="initial" />;
connected={connected} } else if (selectedAgent.startsWith('placeholder-')) {
theme={theme} rightPanel = <EmptyDetailPanel variant="placeholder" onClose={handleClose} />;
onThemeChange={handleThemeChange} } else {
zoom={zoom} rightPanel = (
onZoomChange={handleZoomChange}
/>
<div className="ao-main">
<canvas
ref={canvasRef}
className="ao-canvas"
onClick={handleCanvasClick}
/>
{selectedAgent && (
<SidePanel <SidePanel
agentId={selectedAgent} agentId={selectedAgent}
agentState={agents[selectedAgent]} agentState={agents[selectedAgent]}
pendingTask={pendingTask} pendingTask={pendingTask}
onClose={() => setSelectedAgent(null)} onClose={handleClose}
refreshTrigger={refreshTrigger} refreshTrigger={refreshTrigger}
/> />
)} );
}
return (
<div className="ao-root">
<TopBar connected={connected} reconnectAttempt={reconnectAttempt} />
<div className="ao-main">
<div className="ao-grid-wrap">
<AgentGrid
agents={agents}
notifications={notifications}
selectedAgent={selectedAgent}
onSelectAgent={handleSelectAgent}
onSelectPlaceholder={handleSelectPlaceholder}
/>
</div>
{rightPanel}
</div> </div>
</div> </div>
); );

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -1,72 +0,0 @@
{
"cols": 32,
"rows": 20,
"tileSize": 32,
"floor": [
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,2,2,2,2,2,2,2,2,2,2,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0],
[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
],
"furniture": [
{"type": "desk_monitor", "col": 3, "row": 3, "agent": "stock", "monitors": 3},
{"type": "desk_monitor", "col": 10, "row": 3, "agent": "music", "monitors": 1, "accent": "instrument"},
{"type": "desk_monitor", "col": 17, "row": 3, "agent": "blog", "monitors": 2, "accent": "papers"},
{"type": "desk_monitor", "col": 24, "row": 3, "agent": "realestate", "monitors": 2, "accent": "briefcase"},
{"type": "desk_monitor", "col": 14, "row": 7, "agent": "lotto", "monitors": 1, "accent": "dice"},
{"type": "meeting_table","col": 13, "row": 11,"width": 6, "height": 2},
{"type": "sofa", "col": 2, "row": 17},
{"type": "coffee_machine","col": 5, "row": 16},
{"type": "bookshelf", "col": 27, "row": 16, "height": 3},
{"type": "plant", "col": 1, "row": 1},
{"type": "plant", "col": 30, "row": 1},
{"type": "plant", "col": 1, "row": 14},
{"type": "plant", "col": 30, "row": 14},
{"type": "water_cooler", "col": 8, "row": 17}
],
"waypoints": {
"desk_stock": {"col": 3, "row": 4},
"desk_music": {"col": 10, "row": 4},
"desk_blog": {"col": 17, "row": 4},
"desk_realestate": {"col": 24, "row": 4},
"desk_lotto": {"col": 14, "row": 8},
"meeting": {"col": 16, "row": 13},
"break_room": {"col": 4, "row": 17},
"coffee": {"col": 6, "row": 17},
"water_cooler": {"col": 8, "row": 18}
},
"blocked": [
[3,3],[4,3],[5,3],
[10,3],[11,3],
[17,3],[18,3],[19,3],
[24,3],[25,3],[26,3],
[14,7],[15,7],
[13,11],[14,11],[15,11],[16,11],[17,11],[18,11],
[13,12],[14,12],[15,12],[16,12],[17,12],[18,12],
[2,17],[3,17],
[5,16],[6,16],
[27,16],[27,17],[27,18],
[8,17]
],
"tileTypes": {
"0": "wall",
"1": "floor",
"2": "floor_break"
}
}

Some files were not shown because too many files have changed in this diff Show More