Compare commits
27 Commits
3f5cd32c77
...
feature/st
| Author | SHA1 | Date | |
|---|---|---|---|
| e6659a416a | |||
| 3abd46c0fd | |||
| c42d3fe8d4 | |||
| 1e8542f6c7 | |||
| a11475db57 | |||
| bc2c020f71 | |||
| cd6072727f | |||
| 42ebd5a87c | |||
| 3b66a47316 | |||
| f7323a5b72 | |||
| ccf6d4e551 | |||
| a20315ce34 | |||
| 3fa4dbda3c | |||
| baf34dd7aa | |||
| 4ef76f6cce | |||
| 0bf1233e96 | |||
| ff7ac48c6b | |||
| 329141c732 | |||
| cd3c538eb7 | |||
| 9d2dfad512 | |||
| 42073a5bf3 | |||
| 6b2fcda2af | |||
| acac2cd20e | |||
| 95edc9d232 | |||
| ec22321d56 | |||
| a80b869878 | |||
| 93d5f49cdb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -23,3 +23,6 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
.env.local
|
||||
|
||||
# Superpowers visual companion (mockup files)
|
||||
.superpowers/
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
| `/lotto` | `Lotto` | 로또 추천/통계 |
|
||||
| `/stock` | `Stock` | 주식 뉴스/지수 |
|
||||
| `/stock/trade` | `StockTrade` | 주식 트레이딩 |
|
||||
| `/stock/screener` | `Screener` | 노드 기반 강세주 스크리너 (점수 노드 7 + 위생 게이트 + ATR 포지션 사이저) |
|
||||
| `/realestate` | `Subscription` | 청약 자격·일정 관리<br>• **프로필 탭**: 자치구 5티어 분류(드래그&드롭, PC 전용 / 모바일 read-only), 매칭 임계값 슬라이더, 텔레그램 알림 토글<br>• **카드/매칭 결과**: district 뱃지 + 5티어(S/A/B/C/D) 뱃지 표시<br>• **상세 모달**: 매칭 분석 섹션 (점수 + 사유 + 신청 자격) |
|
||||
| `/realestate/property` | `RealEstate` | 관심 단지 정보 |
|
||||
| `/travel` | `Travel` | 여행 사진 갤러리 (Dark Room 테마) |
|
||||
@@ -85,6 +86,12 @@ proxy: {
|
||||
| 주식 | GET | `/api/stock/news`, `/api/stock/indices` |
|
||||
| 트레이딩 | GET | `/api/trade/balance` |
|
||||
| 트레이딩 | POST | `/api/trade/order` |
|
||||
| 스크리너 | GET | `/api/stock/screener/nodes` |
|
||||
| 스크리너 | GET/PUT | `/api/stock/screener/settings` |
|
||||
| 스크리너 | POST | `/api/stock/screener/run` — body: `{ mode, asof?, weights?, ... }` |
|
||||
| 스크리너 | POST | `/api/stock/screener/snapshot/refresh` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs?limit=N` |
|
||||
| 스크리너 | GET | `/api/stock/screener/runs/:id` |
|
||||
| 포트폴리오 | GET/POST | `/api/portfolio` |
|
||||
| 포트폴리오 | PUT/DELETE | `/api/portfolio/:id` |
|
||||
| 예수금 | PUT | `/api/portfolio/cash` — body: `{ broker, cash }` |
|
||||
|
||||
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
2837
docs/superpowers/plans/2026-05-11-lotto-curator-evolution-plan.md
Normal file
File diff suppressed because it is too large
Load Diff
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
4353
docs/superpowers/plans/2026-05-12-stock-screener-board.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
# Lotto Curator Evolution — Design Spec
|
||||
|
||||
- 일자: 2026-05-11
|
||||
- 범위: `web-ui` (브리핑 탭 재구성), `web-backend/lotto` (스키마·잡), `web-backend/agent-office` (큐레이터·텔레그램)
|
||||
- 컨셉 한 줄: **매주 같은 시간에 큐레이터가 한 번 더 똑똑해진다**
|
||||
|
||||
## 1. 동기와 문제
|
||||
|
||||
현재 `/lotto`는 3탭(브리핑·분석·구매)으로 구성되어 정보가 풍부하지만, 사용자가 5천~1만원 어치를 즐기며 구매하기에 다음 페인이 있다.
|
||||
|
||||
- 분석·통계·브리핑이 모두 *결정용 화면*처럼 노출되어 정보 과다.
|
||||
- 큐레이터가 매주 5세트를 추천하지만, 5세트의 *역할*과 *왜 이 분배인지*가 와닿지 않는다.
|
||||
- 큐레이터·시스템에 시간축이 없다. 매주 동일 알고리즘을 새로 도는 느낌.
|
||||
- 1만원어치 구매 시 5세트로는 부족하다. 추가 게임에 대한 설계가 없다.
|
||||
|
||||
## 2. 컨셉
|
||||
|
||||
다음 두 축으로 강화한다.
|
||||
|
||||
- **서사적 진화**: 큐레이터가 매주 *지난 주를 회고*하고 이번 주 전략으로 이어간다. 자기 추천 결과 + 사용자 실제 구매 결과를 둘 다 회고 데이터로 사용한다.
|
||||
- **포트폴리오 명료성**: 5게임이 단순 5장이 아니라 안정/균형/공격 분배가 그 주 데이터에 따라 동적으로 바뀌고, 그 이유가 한 줄로 와닿는다. 5~20세트로 위계적으로 확장된다.
|
||||
|
||||
## 3. 주간 사이클
|
||||
|
||||
```
|
||||
토 20:35 추첨
|
||||
│
|
||||
일 03:00 추첨결과 sync (기존)
|
||||
↓
|
||||
채점 잡 (신규) → weekly_review INSERT
|
||||
lotto_purchase auto_graded UPDATE
|
||||
│
|
||||
월 09:00 큐레이션 트리거 (lotto_agent.on_schedule)
|
||||
├─ build_retrospective(target_draw)
|
||||
├─ collect_candidates(N=30)
|
||||
├─ build_context (+retrospective)
|
||||
├─ Claude 호출 (회고+계층 규칙)
|
||||
└─ briefings INSERT (4계층 picks)
|
||||
│
|
||||
월 09:05 텔레그램 헤드라인 푸시
|
||||
│
|
||||
월~토 사용자: 사이트 결정 카드 → 모드 선택(5/10/15/20) → 1탭 구매 기록
|
||||
│
|
||||
토 20:35 추첨 → 다음 사이클
|
||||
```
|
||||
|
||||
cron 시간(일 03:00 / 월 09:00)은 운영하며 조정 가능한 기본값.
|
||||
|
||||
## 4. 결정 카드 (브리핑 탭 메인)
|
||||
|
||||
브리핑 탭을 단일 `DecisionCard`로 재구성한다. 정보 위계는 위→아래로:
|
||||
|
||||
1. **헤더** — 회차 + 한 줄 헤드라인 + 신뢰도(0~100, 큐레이터 자기 평가)
|
||||
2. **회고 박스** (▸ 보라색 라벨) — 지난 주 너 + 큐레이터 한 줄 회고. *시간축*의 핵심.
|
||||
3. **헤드라인 + 3줄** — 이번 주 전망 + 근거 3줄(기존 narrative 유지).
|
||||
4. **분배 칩** — 선택 모드까지의 안정/균형/공격 합산 + "왜 이 분배인지" 한 줄.
|
||||
5. **모드 토글** — 4단계 칩(코어 5 / +보너스 5 / +확장 5 / +풀 5).
|
||||
6. **계층 섹션 × 4** — 각 계층마다 타이틀 + 사유 한 줄 + 5장 PickCard. 코어는 항상 펼침, 그 외는 모드에 따라.
|
||||
7. **하단 액션** — "이대로 N세트 구매했음" 한 클릭 → 자동 기록.
|
||||
|
||||
### 4계층 위계
|
||||
|
||||
| 계층 | 누적 게임 | 비용 | 큐레이터의 의도 |
|
||||
|---|---|---|---|
|
||||
| 코어(필수) | 5 | 5천 | 안정 2 / 균형 2 / 공격 1, 그 주 주축 |
|
||||
| + 보너스 | 10 | 1만 | 코어 분배의 공백 보완 |
|
||||
| + 확장 | 15 | 1.5만 | 코어·보너스에 없던 시각(합계 극단·콜드 누적·4주 미등장) |
|
||||
| + 풀 | 20 | 2만 | 한 번도 누르지 않은 패턴(연속·동끝·5수 균등) |
|
||||
|
||||
각 5세트는 *큐레이터가 의도한 한 묶음*이며, 늘어날수록 *서사가 더해지는 구조*. 마지막 모드 선택은 브라우저 `localStorage` 에 `lotto.tier_mode` 키로 저장하여 다음 주 진입 시 디폴트로 사용한다(서버 저장 X — 사용자 디바이스 단위 기억).
|
||||
|
||||
### 분석 탭은 "Deep Dive" 자료실로 강등
|
||||
|
||||
- 라벨 변경: `📊 분석·통계` → `📚 자료실 / Deep Dive`
|
||||
- 첫 진입 시 모든 패널 접힘
|
||||
- 기존 패널 모두 보존 (CombinedRecommendPanel, ReportPanel, 시뮬레이션, 통계, 빈도, PersonalAnalysisPanel, 수동 추천, 히스토리)
|
||||
- PerformanceBanner는 결정 카드 헤더와 역할 중복 없도록 자료실에만 둠
|
||||
|
||||
## 5. 데이터 모델
|
||||
|
||||
### 신규 테이블 — `weekly_review`
|
||||
|
||||
```sql
|
||||
CREATE TABLE weekly_review (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
draw_no INTEGER NOT NULL UNIQUE,
|
||||
|
||||
-- 큐레이터 자기 평가 (briefings.picks vs 추첨)
|
||||
curator_avg_match REAL,
|
||||
curator_best_tier TEXT, -- 안정 | 균형 | 공격
|
||||
curator_best_match INTEGER,
|
||||
curator_5plus_prizes INTEGER, -- 3개↑ 일치 카운트(5등 이상)
|
||||
|
||||
-- 사용자 구매 평가 (lotto_purchase vs 추첨)
|
||||
user_avg_match REAL,
|
||||
user_best_match INTEGER,
|
||||
user_5plus_prizes INTEGER,
|
||||
|
||||
-- 패턴 갭 (서사 재료)
|
||||
user_pattern_summary TEXT,
|
||||
draw_pattern_summary TEXT,
|
||||
pattern_delta TEXT, -- "너 저번호 편향 +1.2 / 합계 -18"
|
||||
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
### `lotto_purchase` 컬럼 추가
|
||||
|
||||
```sql
|
||||
ALTER TABLE lotto_purchase ADD COLUMN numbers TEXT; -- JSON [3,11,17,25,33,41]
|
||||
ALTER TABLE lotto_purchase ADD COLUMN match_count INTEGER;
|
||||
ALTER TABLE lotto_purchase ADD COLUMN auto_graded INTEGER DEFAULT 0;
|
||||
ALTER TABLE lotto_purchase ADD COLUMN curator_tier TEXT; -- core | bonus | extended | pool
|
||||
ALTER TABLE lotto_purchase ADD COLUMN curator_role TEXT; -- 안정 | 균형 | 공격
|
||||
```
|
||||
|
||||
### `briefings.picks` 구조 변경
|
||||
|
||||
JSON 컬럼을 4계층 구조로 마이그레이션:
|
||||
|
||||
```json
|
||||
{
|
||||
"core": [/* 5세트 */],
|
||||
"bonus": [/* 5세트 */],
|
||||
"extended": [/* 5세트 */],
|
||||
"pool": [/* 5세트 */]
|
||||
}
|
||||
```
|
||||
|
||||
기존 단일 배열 데이터는 `core` 키에만 매핑하고 나머지 키는 빈 배열로 채우는 1회 마이그레이션 스크립트.
|
||||
|
||||
## 6. 큐레이터 변경
|
||||
|
||||
### 출력 스키마 (`agent-office/curator/schema.py`)
|
||||
|
||||
```python
|
||||
class CuratorOutput(BaseModel):
|
||||
core_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
bonus_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
extended_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
pool_picks: List[Pick] = Field(min_length=5, max_length=5)
|
||||
tier_rationale: TierRationale # bonus / extended / pool 각 30자 이내
|
||||
narrative: Narrative # retrospective(60자 이내) 필드 추가
|
||||
confidence: int # 0~100
|
||||
```
|
||||
|
||||
### SYSTEM_PROMPT 추가 규칙
|
||||
|
||||
```
|
||||
회고 규칙:
|
||||
- context.retrospective 가 있으면 narrative.retrospective 에 한 줄(60자 이내).
|
||||
- 큐레이터 자기 결과(curator_avg, best_tier) + 사용자 결과(user_avg, pattern_delta) 둘 다 짚을 것.
|
||||
- 이번 주 코어 분배는 회고에 근거해 조정. 사유는 narrative.headline 에 한 줄로.
|
||||
|
||||
계층별 큐레이션 규칙:
|
||||
- core_picks (5): 안정 2 / 균형 2 / 공격 1. 그 주 주축.
|
||||
- bonus_picks (5): 코어 분배의 공백을 메움. 코어와 상보적.
|
||||
- extended_picks (5): 코어·보너스에 없는 시각(합계 극단 / 콜드 누적 / 4주 미등장).
|
||||
- pool_picks (5): 이번 주 한 번도 누르지 않은 패턴(연속·동끝·5수 균등).
|
||||
- tier_rationale 의 3개 키(bonus·extended·pool)에 각각 30자 이내 사유.
|
||||
- 후보에 없는 번호 조합은 절대 사용 금지(기존 규칙 유지).
|
||||
```
|
||||
|
||||
### 회고 컨텍스트 — `agent-office/curator/retrospective.py` (신규)
|
||||
|
||||
```python
|
||||
def build_retrospective(target_draw_no: int) -> dict | None:
|
||||
last = lotto_get_review(target_draw_no - 1)
|
||||
prev3 = lotto_get_reviews(target_draw_no - 4, target_draw_no - 2)
|
||||
if not last:
|
||||
return None
|
||||
return {
|
||||
"last_draw": {
|
||||
"draw_no": last["draw_no"],
|
||||
"curator_avg": last["curator_avg_match"],
|
||||
"curator_best_tier": last["curator_best_tier"],
|
||||
"user_avg": last["user_avg_match"],
|
||||
"user_5plus": last["user_5plus_prizes"],
|
||||
"pattern_delta": last["pattern_delta"],
|
||||
},
|
||||
"trend_4w": {
|
||||
"curator_avg_4w": mean(curator_avg_match for r in [last, *prev3]),
|
||||
"user_avg_4w": mean(user_avg_match for r in [last, *prev3] if user_avg_match is not None),
|
||||
"user_persistent_bias": _detect_bias([last, *prev3]), # 3주↑ 유지된 패턴 편향(예: "저번호 편향")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 후보 풀 N=30
|
||||
|
||||
`collect_candidates(n=30)` — 20세트 선별 + 다양성 여유. 기존 4개 소스(simulation/heatmap/statistics/meta) 추출량을 비례 확대.
|
||||
|
||||
## 7. 자동 채점 잡 — `lotto/app/jobs/grade_weekly_review.py`
|
||||
|
||||
```
|
||||
실행: 매주 일요일 03:00 KST (cron)
|
||||
입력: 가장 최근 sync된 추첨 회차
|
||||
처리:
|
||||
1) briefings 에서 해당 회차의 4계층 picks 로드 (없으면 curator_* NULL)
|
||||
2) lotto_purchase 에서 해당 회차의 사용자 구매 로드 (없으면 user_* NULL)
|
||||
3) 각 세트별 일치 수 계산 → 큐레이터/사용자 집계
|
||||
4) 패턴 요약(저번호·홀짝·합계 평균) → user/draw_pattern_summary
|
||||
5) 패턴 갭 한 줄(가장 큰 격차 1~2개) → pattern_delta
|
||||
6) weekly_review UPSERT (draw_no 유니크)
|
||||
7) lotto_purchase 채점:
|
||||
- 일치 3개 → prize=5000, auto_graded=1
|
||||
- 일치 4개 → prize=NULL, note 에 "4등 가능성 — 동행복권 확인" 플래그
|
||||
- 일치 5+ → prize=NULL, note 에 "🚨 큰 당첨 가능성 — 즉시 확인" 플래그
|
||||
+ agent-office HTTP webhook(`POST /api/agent-office/notify/lotto-prize`)
|
||||
호출하여 텔레그램 별도 알림 트리거
|
||||
- numbers NULL 인 행은 스킵
|
||||
```
|
||||
|
||||
## 8. 텔레그램 알림 — `agent-office/notifiers/telegram_lotto.py` (신규)
|
||||
|
||||
큐레이션 성공 후 `lotto_agent` 가 호출. 발송 실패는 try/except 로 흡수(briefing 저장과 분리).
|
||||
4등 이상 당첨 알림은 lotto-backend 채점 잡이 `POST /api/agent-office/notify/lotto-prize` webhook 으로 트리거(agent-office 측 라우터 신규 추가).
|
||||
|
||||
```
|
||||
🎟 1154회 · 큐레이션 떴음
|
||||
|
||||
"이번 주는 안정 +1, 콜드 누적 보강."
|
||||
신뢰도 72 · 분배 안정 3·균형 1·공격 1
|
||||
|
||||
▸ 회고: 너 2.0 / 나 1.8
|
||||
너 저번호 편향 → 보너스 고번호 보강
|
||||
|
||||
👉 결정 카드 보러가기 (https://gahusb.synology.me/lotto)
|
||||
```
|
||||
|
||||
회고 단락은 retrospective 가 있을 때만(첫 주 생략).
|
||||
|
||||
## 9. 프론트 변경
|
||||
|
||||
### 파일 변경 맵
|
||||
|
||||
| 파일 | 종류 | 내용 |
|
||||
|------|------|------|
|
||||
| `pages/lotto/Functions.jsx` | 수정 | 분석탭 라벨 변경 |
|
||||
| `pages/lotto/tabs/BriefingTab.jsx` | 수정 | DecisionCard 단일로 재구성 |
|
||||
| `pages/lotto/components/decision/DecisionCard.jsx` | 신규 | 결정 카드 메인 |
|
||||
| `pages/lotto/components/decision/RetrospectiveBox.jsx` | 신규 | 회고 박스 |
|
||||
| `pages/lotto/components/decision/TierModeToggle.jsx` | 신규 | 4단계 칩 토글 |
|
||||
| `pages/lotto/components/decision/TierSection.jsx` | 신규 | 한 계층 영역(타이틀+사유+5장) |
|
||||
| `pages/lotto/components/decision/PickCard.jsx` | 신규 | 한 세트 카드(역할+번호+사유) |
|
||||
| `pages/lotto/components/decision/BulkPurchaseButton.jsx` | 신규 | 원클릭 구매 |
|
||||
| `pages/lotto/components/briefing/*` | 삭제·이동 | DecisionCard 하위로 흡수, CuratorUsageFooter 는 자료실 이동 |
|
||||
| `pages/lotto/components/PurchasePanel.jsx` | 수정 | auto_graded 표시 + 4등 이상 플래그 |
|
||||
| `pages/lotto/components/PurchaseTrendChart.jsx` | 신규 | 4주 추세 라인(너 vs 큐레이터 평균 일치) |
|
||||
| `pages/lotto/hooks/useBriefing.js` | 수정 | 4계층 + retrospective 수용 |
|
||||
| `pages/lotto/hooks/useReview.js` | 신규 | weekly_review 로드 |
|
||||
| `pages/lotto/hooks/usePurchases.js` | 수정 | bulkPurchase 추가 |
|
||||
| `api.js` | 수정 | getLatestReview, getReviewHistory, bulkPurchase 헬퍼 |
|
||||
|
||||
### 컴포넌트 격리 원칙
|
||||
|
||||
- `DecisionCard` 는 `briefing` + `review` 두 객체만 props 로 받음(내부 hook 호출 X).
|
||||
- `TierSection` 은 `tier`, `picks`, `rationale` 만 받아 4번 재사용.
|
||||
- `BulkPurchaseButton` 은 `draw_no`, `tier_mode`, `sets`, `amount` 4개로 작동.
|
||||
|
||||
## 10. 백엔드 변경
|
||||
|
||||
### `web-backend/lotto/`
|
||||
|
||||
| 파일 | 종류 | 내용 |
|
||||
|------|------|------|
|
||||
| `app/db/migrations/00X_weekly_review.sql` | 신규 | 테이블 생성 |
|
||||
| `app/db/migrations/00X_purchase_grading.sql` | 신규 | lotto_purchase 컬럼 추가 |
|
||||
| `app/db/migrations/00X_briefings_tiers.sql` | 신규 | briefings.picks 4계층 마이그레이션 |
|
||||
| `app/jobs/grade_weekly_review.py` | 신규 | 채점 잡 |
|
||||
| `app/curator_helpers.py` | 수정 | collect_candidates(N=30) 기본값, build_context 에 retrospective 합치기 |
|
||||
| `app/routers/briefing.py` | 수정 | BriefingRequest 4계층 + narrative.retrospective 수용 |
|
||||
| `app/routers/review.py` | 신규 | GET /api/lotto/review/latest, GET /api/lotto/review/history?limit=N |
|
||||
| `app/routers/purchase.py` | 수정 | POST /api/lotto/purchase/bulk |
|
||||
| `app/cron.py` (또는 compose 스케줄러) | 수정 | 채점 잡 일 03:00 등록 |
|
||||
|
||||
### `web-backend/agent-office/`
|
||||
|
||||
| 파일 | 종류 | 내용 |
|
||||
|------|------|------|
|
||||
| `app/curator/retrospective.py` | 신규 | build_retrospective |
|
||||
| `app/curator/schema.py` | 수정 | 4계층 + tier_rationale + narrative.retrospective |
|
||||
| `app/curator/prompt.py` | 수정 | 회고·계층 규칙 추가 |
|
||||
| `app/curator/pipeline.py` | 수정 | retrospective 빌드 호출, 4계층 직렬화 |
|
||||
| `app/agents/lotto.py` | 수정 | on_schedule 월 09:00, 성공 시 텔레그램 호출 |
|
||||
| `app/notifiers/telegram_lotto.py` | 신규 | 알림 포맷·발송(큐레이션 완료, 4등 이상 당첨 알림 둘 다) |
|
||||
| `app/routers/notify.py` | 신규 | `POST /api/agent-office/notify/lotto-prize` — lotto-backend 채점 잡이 호출 |
|
||||
| `app/service_proxy.py` | 수정 | review 헬퍼 추가 |
|
||||
|
||||
## 11. API 추가·변경
|
||||
|
||||
| 메서드 | 경로 | 설명 |
|
||||
|--------|------|------|
|
||||
| GET | `/api/lotto/review/latest` | 최신 weekly_review 1건 |
|
||||
| GET | `/api/lotto/review/history?limit=N` | 최근 N건 (4주 추세 차트용) |
|
||||
| POST | `/api/lotto/purchase/bulk` | 결정 카드 원클릭 — body: `{ draw_no, tier_mode, sets, amount }` |
|
||||
| POST | `/api/agent-office/notify/lotto-prize` | 4등 이상 당첨 시 lotto-backend 가 트리거 — body: `{ draw_no, match_count, numbers, purchase_id }` |
|
||||
|
||||
기존 엔드포인트는 그대로 유지(스키마 호환).
|
||||
|
||||
## 12. 에러 처리 / 격리
|
||||
|
||||
| 단계 | 실패 | 처리 |
|
||||
|------|------|------|
|
||||
| 추첨결과 sync | 동행복권 API down | 기존 정책(재시도). 채점 잡은 자동 지연만. |
|
||||
| 채점 — 큐레이터 picks 없음 | 첫 주, 큐레이션 실패 회차 | curator_* NULL 로 INSERT |
|
||||
| 채점 — 사용자 구매 없음 | 그 주 미구매 | user_* NULL |
|
||||
| 채점 — numbers NULL 행 | 마이그레이션 이전 데이터 | 스킵, auto_graded=0 유지 |
|
||||
| build_retrospective — review 없음 | 첫 주 | None 반환 → 프롬프트 분기 자연 처리 |
|
||||
| Claude 스키마 실패 | 4계층 미준수 등 | 기존 1회 retry, 2회 실패 시 텔레그램 에러 알림 |
|
||||
| 텔레그램 발송 실패 | 봇/네트워크 | try/except, 로그만. briefing 저장은 영향 없음 |
|
||||
| bulk purchase — briefing 없음 | 큐레이션 실패 회차 | 400 + 토스트 |
|
||||
| bulk purchase — 중복 호출 | 더블클릭 | (draw_no, tier_mode) 유니크 → idempotent |
|
||||
| 자동채점 — 4등 이상 | 큰 당첨 | prize NULL + 메모 플래그 + 텔레그램 별도 알림 |
|
||||
|
||||
## 13. 테스트
|
||||
|
||||
### 백엔드 (`lotto/`)
|
||||
|
||||
- `grade_weekly_review`: (a) 정상 (b) user 구매 없음 (c) numbers NULL 스킵 (d) 일치 3개 → prize 5000 (e) 일치 4개 → 메모 플래그
|
||||
- 마이그레이션: 빈 DB → 더미 → 잡 실행 → 행 정확
|
||||
- briefings 마이그레이션: 구 단일 picks → core 매핑, 나머지 빈 배열
|
||||
- `POST /purchase/bulk`: 정상 / 잘못된 tier_mode / briefing 없음 / 중복 호출
|
||||
- `GET /review/latest`: 데이터 있음 / 빈 DB → 404
|
||||
|
||||
### 큐레이터 (`agent-office/curator/`)
|
||||
|
||||
- `build_retrospective`: review 1건 / 4건 / 0건
|
||||
- `validate_response`: 정상 / 계층 누락 / 후보 외 번호 / tier_rationale 누락
|
||||
- `curate_weekly` (Claude API mock): retrospective 있음·없음 / 1차 실패 → 2차 성공 / 2회 실패
|
||||
- `telegram_lotto.format`: retrospective 있음·없음
|
||||
|
||||
### 프론트
|
||||
|
||||
- `DecisionCard` 수동: retrospective 있음·없음 / 모드 토글 5/10/15/20 / confidence 색
|
||||
- `TierModeToggle` 단위: onChange 콜백 정확
|
||||
- `BulkPurchaseButton` 수동 E2E: 클릭 → POST → 토스트 → 구매탭 갱신
|
||||
- 자료실 탭 수동: 첫 진입 모두 접힘
|
||||
- 모바일: DecisionCard 좁은 화면에서 깨짐 없음
|
||||
|
||||
## 14. 운영 점검 (배포 후 1주차)
|
||||
|
||||
수동으로 확인:
|
||||
|
||||
1. 일 03:00 채점 잡 1회 실행(`weekly_review` 1행 추가)
|
||||
2. 월 09:00 큐레이션 실행(`briefings` 1행, 4계층 5×4=20개)
|
||||
3. 텔레그램 알림 도착(회고 단락 정확 포함/생략)
|
||||
4. 결정 카드 렌더링 정상(모바일 + PC)
|
||||
5. 원클릭 구매 정확 N건 INSERT
|
||||
6. cron 시간(03:00 / 09:00) 운영 패턴에 맞게 조정
|
||||
|
||||
## 15. Out of Scope
|
||||
|
||||
- 4등 이상 당첨금 자동 입력(회차별 변동, 사용자 PUT 으로 갱신)
|
||||
- 큐레이터 호출 재무 비용 모니터링 강화(기존 `curator_usage` 그대로)
|
||||
- 분석 탭 패널 자체의 리팩토링(라벨·디폴트 접힘만 변경)
|
||||
- 1만원 외 임의 분량(7세트 등) 토글(4계층 5단위로 고정)
|
||||
822
docs/superpowers/specs/2026-05-12-stock-screener-board-design.md
Normal file
822
docs/superpowers/specs/2026-05-12-stock-screener-board-design.md
Normal file
@@ -0,0 +1,822 @@
|
||||
# Stock Screener Board — 설계 문서 (MVP 슬라이스 1)
|
||||
|
||||
- **상태**: 설계 (Draft)
|
||||
- **작성일**: 2026-05-12
|
||||
- **대상 프로젝트**: `web-ui` (프론트엔드) + `web-backend/stock-lab` (백엔드) + `web-backend/agent-office` (스케줄러/텔레그램)
|
||||
- **저자**: 개인 웹 플랫폼 CEO + Claude (brainstorming)
|
||||
|
||||
---
|
||||
|
||||
## 1. 배경 & 목표
|
||||
|
||||
현재 `/stock`은 뉴스·지수·공포탐욕, `/stock/trade`는 포트폴리오·매매·AI 코치까지 다룹니다. **시장 전체에서 강세주를 발굴하는 기능은 없습니다.**
|
||||
|
||||
이 작업은 KRX 전체 종목을 매일 분석해 강세주 후보를 점수화·순위화하고, 평일 장 마감 후 텔레그램으로 자동 전송하는 **노드 기반 분석 보드**를 만듭니다. 노드 인터페이스를 일관되게 정의해 후속 슬라이스에서 노드 캔버스 UI·AI 뉴스 노드·백테스트로 자연스럽게 확장 가능한 구조를 둡니다.
|
||||
|
||||
### 비전 (장기)
|
||||
|
||||
n8n 같은 노드 캔버스에서 시그널 노드를 연결·점수화하고, 결과를 표·텔레그램으로 받는 개인용 스크리닝/분석 워크벤치.
|
||||
|
||||
### 본 슬라이스 (MVP)
|
||||
|
||||
| 요소 | 범위 |
|
||||
|------|------|
|
||||
| 데이터 | pykrx로 매일 KRX 전종목 일봉 + 외국인/기관 수급 → SQLite 캐시 |
|
||||
| 분석 노드 | 점수 7개 + 위생 게이트 1개 = 총 8개 |
|
||||
| 결합 | 가중합 (게이트 통과군 내 백분위 정규화 기반) |
|
||||
| 출력 | Top N(기본 20) 결과 표 + 진입가/손절/익절 + 텔레그램 |
|
||||
| 실행 | 평일 16:30 KST 자동 + 사용자 수동 미리보기 |
|
||||
| UI | `/stock/screener` 별도 페이지, 좌(설정)-중(표)-우(히스토리) |
|
||||
| 자동 잡 | `agent-office`가 트리거, 텔레그램 전송 책임 |
|
||||
|
||||
### 비목표 (후속 슬라이스에 명시 예약)
|
||||
|
||||
1. AI 뉴스 호재/악재 노드
|
||||
2. 노드 캔버스 UI (react-flow)
|
||||
3. 주간 자가학습 (가중치 자동 조정 제안)
|
||||
4. DART 공시·재무제표 노드
|
||||
5. 분봉 기반 노드 (한투 API)
|
||||
6. 진짜 미너비니 VCP (베이스 카운트·피벗 포인트)
|
||||
7. 멀티 프리셋 ("공격형"/"안정형")
|
||||
8. 백테스트 화면
|
||||
9. KRX 호가단위 적용
|
||||
10. 메트릭/대시보드 (Prometheus 등)
|
||||
|
||||
---
|
||||
|
||||
## 2. 전체 아키텍처
|
||||
|
||||
```
|
||||
[agent-office 평일 16:30 KST] [사용자: Stock 스크리너 페이지]
|
||||
│ │
|
||||
▼ ▼
|
||||
POST /api/stock/screener/snapshot/refresh POST /api/stock/screener/run
|
||||
POST /api/stock/screener/run {mode:"auto"} {mode:"preview"|"manual_save"}
|
||||
│ │
|
||||
└──────────► Screener.run() ◄──────────────────┘
|
||||
│
|
||||
▼
|
||||
ScreenContext.load(asof)
|
||||
(KRX 마스터·일봉·수급 SQLite 캐시)
|
||||
│
|
||||
▼
|
||||
HygieneGate.filter() ← Survivors ~500-800종
|
||||
│
|
||||
▼
|
||||
[ScoreNode.compute() × 7 활성 노드]
|
||||
│
|
||||
▼
|
||||
combine + rank Top N
|
||||
│
|
||||
▼
|
||||
position_sizer (entry/stop/target)
|
||||
│
|
||||
┌─────────────┴───────────────┐
|
||||
▼ ▼
|
||||
screener_runs + screener_results 응답 JSON (results, telegram_payload)
|
||||
(mode='auto'·'manual_save') │
|
||||
▼
|
||||
agent-office가 telegram_payload 전송
|
||||
(mode='auto')
|
||||
```
|
||||
|
||||
데이터 신선도 가정: pykrx의 외국인/기관 수급은 KRX 마감 후 30-60분 뒤 갱신. **16:30 KST 트리거는 안전 마진**.
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 컴포넌트 구조 (stock-lab)
|
||||
|
||||
### 3.1 디렉토리
|
||||
|
||||
```
|
||||
web-backend/stock-lab/app/
|
||||
├─ main.py # router.include_router(screener_router) 1줄 추가
|
||||
├─ db.py
|
||||
├─ price_fetcher.py
|
||||
├─ scraper.py
|
||||
├─ ai_summarizer.py
|
||||
├─ holidays.json
|
||||
├─ test_*.py # 기존
|
||||
├─ test_screener_*.py # 신규 (각 노드/엔진/라우터)
|
||||
└─ screener/ # ← NEW
|
||||
├─ __init__.py
|
||||
├─ router.py # FastAPI: /api/stock/screener/*
|
||||
├─ schemas.py # Pydantic 요청/응답
|
||||
├─ engine.py # Screener / ScreenContext / ScreenerResult / combine()
|
||||
├─ snapshot.py # pykrx 일봉·수급 갱신
|
||||
├─ position_sizer.py # ATR 기반 진입/손절/익절
|
||||
├─ registry.py # NODE_REGISTRY, GATE_REGISTRY
|
||||
├─ telegram.py # agent-office payload 빌더 (전송 책임은 agent-office)
|
||||
├─ _test_fixtures.py # 합성 ScreenContext 헬퍼
|
||||
└─ nodes/
|
||||
├─ __init__.py
|
||||
├─ base.py # ScoreNode, GateNode 추상
|
||||
├─ hygiene.py
|
||||
├─ foreign_buy.py
|
||||
├─ volume_surge.py
|
||||
├─ momentum.py
|
||||
├─ high52w.py
|
||||
├─ rs_rating.py
|
||||
├─ ma_alignment.py
|
||||
└─ vcp_lite.py
|
||||
```
|
||||
|
||||
### 3.2 핵심 추상
|
||||
|
||||
```python
|
||||
# nodes/base.py
|
||||
class ScoreNode(ABC):
|
||||
name: ClassVar[str] # "foreign_buy"
|
||||
label: ClassVar[str] # "외국인 누적 순매수"
|
||||
default_params: ClassVar[dict]
|
||||
param_schema: ClassVar[dict] # 프론트 폼 자동 생성용 JSON Schema
|
||||
@abstractmethod
|
||||
def compute(self, ctx: "ScreenContext", params: dict) -> "pd.Series":
|
||||
"""index=ticker, dtype=float, range 0..100."""
|
||||
|
||||
class GateNode(ABC):
|
||||
name: ClassVar[str]
|
||||
label: ClassVar[str]
|
||||
default_params: ClassVar[dict]
|
||||
param_schema: ClassVar[dict]
|
||||
@abstractmethod
|
||||
def filter(self, ctx: "ScreenContext", params: dict) -> "pd.Index":
|
||||
"""returns surviving tickers."""
|
||||
|
||||
# engine.py
|
||||
@dataclass(frozen=True)
|
||||
class ScreenContext:
|
||||
prices: pd.DataFrame # long form: date·ticker·open·high·low·close·volume·value
|
||||
flow: pd.DataFrame # date·ticker·foreign_net·institution_net
|
||||
master: pd.DataFrame # ticker·name·market·market_cap·is_managed·listed_date·is_preferred·is_spac
|
||||
kospi: pd.Series # date → close (시장 비교용)
|
||||
asof: datetime.date
|
||||
@classmethod
|
||||
def load(cls, asof: datetime.date) -> "ScreenContext": ...
|
||||
def restrict(self, tickers) -> "ScreenContext": ...
|
||||
|
||||
class Screener:
|
||||
def __init__(self, gate: GateNode, score_nodes: list[ScoreNode], weights: dict[str, float],
|
||||
node_params: dict[str, dict], gate_params: dict, top_n: int,
|
||||
sizer_params: dict):
|
||||
...
|
||||
def run(self, ctx: ScreenContext) -> "ScreenerResult":
|
||||
survivors = self.gate.filter(ctx, self.gate_params)
|
||||
scoped = ctx.restrict(survivors)
|
||||
active = [n for n in self.score_nodes if self.weights.get(n.name, 0) > 0]
|
||||
scores = {n.name: n.compute(scoped, self.node_params.get(n.name, {})) for n in active}
|
||||
total = combine(scores, self.weights)
|
||||
ranked = total.sort_values(ascending=False).head(self.top_n)
|
||||
rows = position_sizer.expand(ranked, scoped, self.sizer_params)
|
||||
return ScreenerResult(rows=rows, scores=scores, weights=self.weights,
|
||||
survivors_count=len(survivors), warnings=[...])
|
||||
```
|
||||
|
||||
### 3.3 registry
|
||||
|
||||
```python
|
||||
# registry.py
|
||||
from .nodes import (foreign_buy, volume_surge, momentum, high52w,
|
||||
rs_rating, ma_alignment, vcp_lite, hygiene)
|
||||
|
||||
NODE_REGISTRY: dict[str, type[ScoreNode]] = {
|
||||
"foreign_buy": foreign_buy.ForeignBuy,
|
||||
"volume_surge": volume_surge.VolumeSurge,
|
||||
"momentum": momentum.Momentum20,
|
||||
"high52w": high52w.High52WProximity,
|
||||
"rs_rating": rs_rating.RsRating,
|
||||
"ma_alignment": ma_alignment.MaAlignment,
|
||||
"vcp_lite": vcp_lite.VcpLite,
|
||||
}
|
||||
GATE_REGISTRY: dict[str, type[GateNode]] = {
|
||||
"hygiene": hygiene.HygieneGate,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 데이터 모델 (stock.db 신규 7테이블)
|
||||
|
||||
### 4.1 KRX 캐시 (3테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS krx_master (
|
||||
ticker TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
market TEXT NOT NULL, -- 'KOSPI'|'KOSDAQ'
|
||||
market_cap INTEGER, -- 원, nullable (pykrx 누락 케이스)
|
||||
is_managed INTEGER NOT NULL DEFAULT 0,
|
||||
is_preferred INTEGER NOT NULL DEFAULT 0,
|
||||
is_spac INTEGER NOT NULL DEFAULT 0,
|
||||
listed_date TEXT, -- 'YYYY-MM-DD'
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS krx_daily_prices (
|
||||
ticker TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
open INTEGER, high INTEGER, low INTEGER, close INTEGER,
|
||||
volume INTEGER,
|
||||
value INTEGER, -- 거래대금(원)
|
||||
PRIMARY KEY (ticker, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_prices_date ON krx_daily_prices(date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS krx_flow (
|
||||
ticker TEXT NOT NULL,
|
||||
date TEXT NOT NULL,
|
||||
foreign_net INTEGER, -- 원
|
||||
institution_net INTEGER,
|
||||
PRIMARY KEY (ticker, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_flow_date ON krx_flow(date);
|
||||
```
|
||||
|
||||
**용량**: KRX 2,700종목 × 252거래일 × 5년 ≈ 340만 행. SQLite 충분 (수십 MB).
|
||||
**갱신**: 마스터는 매일 전체 재기록, 일봉·수급은 당일 행 upsert.
|
||||
|
||||
**초기 백필 (최초 배포 시 1회)**: 백분위 정규화·52주 신고가·RS Rating(1년 수익률)·MA200 계산을 위해 **최소 1년(252거래일), 권장 2년**의 일봉·수급을 시드 데이터로 백필. `snapshot.py`에 `backfill(start_date, end_date)` 함수를 두고 첫 배포·이전 캐시 손실 시 수동 호출. 자동 잡은 일일 증분만.
|
||||
|
||||
### 4.2 사용자 설정 (싱글톤 1테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS screener_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
weights_json TEXT NOT NULL, -- {"foreign_buy":1.0, ...}
|
||||
node_params_json TEXT NOT NULL, -- {"foreign_buy":{"window_days":5}, ...}
|
||||
gate_params_json TEXT NOT NULL, -- {"min_market_cap_won":50_000_000_000, ...}
|
||||
top_n INTEGER NOT NULL DEFAULT 20,
|
||||
rr_ratio REAL NOT NULL DEFAULT 2.0,
|
||||
atr_window INTEGER NOT NULL DEFAULT 14,
|
||||
atr_stop_mult REAL NOT NULL DEFAULT 2.0,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
```
|
||||
|
||||
`ensure_schema()` 시 초기 row 삽입 (디폴트 가중치 §6 참조).
|
||||
|
||||
### 4.3 실행 스냅샷 (2테이블)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS screener_runs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
asof TEXT NOT NULL,
|
||||
mode TEXT NOT NULL, -- 'auto' | 'manual_save'
|
||||
status TEXT NOT NULL, -- 'success' | 'failed' | 'skipped_holiday'
|
||||
error TEXT,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
weights_json TEXT NOT NULL,
|
||||
node_params_json TEXT NOT NULL,
|
||||
gate_params_json TEXT NOT NULL,
|
||||
top_n INTEGER NOT NULL,
|
||||
survivors_count INTEGER,
|
||||
telegram_sent INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_runs_asof ON screener_runs(asof DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS screener_results (
|
||||
run_id INTEGER NOT NULL,
|
||||
rank INTEGER NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
total_score REAL NOT NULL,
|
||||
scores_json TEXT NOT NULL,
|
||||
close INTEGER,
|
||||
market_cap INTEGER,
|
||||
entry_price INTEGER,
|
||||
stop_price INTEGER,
|
||||
target_price INTEGER,
|
||||
atr14 REAL,
|
||||
PRIMARY KEY (run_id, ticker),
|
||||
FOREIGN KEY (run_id) REFERENCES screener_runs(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_results_run_rank ON screener_results(run_id, rank);
|
||||
```
|
||||
|
||||
**`mode='preview'`는 저장하지 않습니다.** `auto`·`manual_save`만 행을 만듭니다.
|
||||
보관 기간 정책 없음 (디스크 부담 미미). 후속에서 cleanup 잡 필요시 추가.
|
||||
|
||||
### 4.4 마이그레이션 방식
|
||||
|
||||
stock-lab의 기존 `db.py` 패턴(`CREATE TABLE IF NOT EXISTS`)을 그대로 따릅니다. `screener/snapshot.py`·`screener/engine.py` import 시점에 1회 `ensure_screener_schema()` 호출. 별도 alembic 도입은 본 작업 스코프 밖.
|
||||
|
||||
---
|
||||
|
||||
## 5. 노드 8개 알고리즘
|
||||
|
||||
모든 점수 노드는 0~100 정수로 정규화. 표준 정규화는 **게이트 통과군 내 백분위(percentile)**, 룰 기반이 더 자연스러운 노드(이평선·52주 근접도)는 룰을 사용.
|
||||
|
||||
### 5.1 위생 게이트 — `HygieneGate` (점수 ❌)
|
||||
|
||||
```text
|
||||
params:
|
||||
min_market_cap_won = 50_000_000_000 # 500억 이상
|
||||
min_avg_value_won = 500_000_000 # 20일 평균 거래대금 5억 이상
|
||||
min_listed_days = 60 # 신규 상장 60일 미만 제외
|
||||
skip_managed = true
|
||||
skip_preferred = true
|
||||
skip_spac = true
|
||||
skip_halted_days = 3 # 최근 3일 거래정지(close 또는 volume=0)
|
||||
통과 조건: 위 AND market_cap NOT NULL AND close NOT NULL
|
||||
출력: 통과 종목 Index (보통 500~800종)
|
||||
```
|
||||
|
||||
### 5.2 외국인 누적 순매수 — `ForeignBuy`
|
||||
|
||||
```text
|
||||
params: window_days = 5
|
||||
raw = sum(foreign_net[-5:]) / market_cap # 시총 대비 비율
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: foreign_net_sum, market_cap, raw_ratio_pct
|
||||
```
|
||||
|
||||
### 5.3 거래량 급증 — `VolumeSurge`
|
||||
|
||||
```text
|
||||
params: baseline_days = 20, eval_days = 3
|
||||
baseline = mean(volume[-23:-3])
|
||||
recent = mean(volume[-3:])
|
||||
raw = log1p(recent / baseline) # 극값 평탄화
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: baseline, recent, ratio
|
||||
```
|
||||
|
||||
### 5.4 20일 모멘텀 — `Momentum20`
|
||||
|
||||
```text
|
||||
params: window_days = 20
|
||||
raw = close[today] / close[today - 20] - 1
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: return_20d_pct
|
||||
```
|
||||
|
||||
### 5.5 52주 신고가 근접도 — `High52WProximity` (룰 기반)
|
||||
|
||||
```text
|
||||
params: window_days = 252
|
||||
high_52w = max(high[-252:])
|
||||
proximity = close / high_52w # 0..1
|
||||
score = clip((proximity - 0.7) / 0.3, 0, 1) × 100
|
||||
# 70% 미만 = 0, 100% 도달 = 100, 선형
|
||||
debug: high_52w, proximity_pct
|
||||
```
|
||||
|
||||
### 5.6 RS Rating — `RsRating`
|
||||
|
||||
```text
|
||||
params: weights = {3m:2, 6m:1, 9m:1, 12m:1} # IBD 표준 가중
|
||||
for k in [63, 126, 189, 252] 거래일:
|
||||
r_stock = close[t]/close[t-k] - 1
|
||||
r_kospi = kospi[t]/kospi[t-k] - 1
|
||||
excess_k = r_stock - r_kospi
|
||||
raw = Σ w_k × excess_k
|
||||
score = percentile_rank(raw, 통과군) × 100 # IBD RS Rating 정의
|
||||
debug: excess_1y, excess_3m, raw
|
||||
```
|
||||
|
||||
### 5.7 이평선 정배열 — `MaAlignment` (룰 기반)
|
||||
|
||||
```text
|
||||
params: ma_periods = [50, 150, 200]
|
||||
5개 조건의 만족 개수 / 5 × 100:
|
||||
① close > MA50
|
||||
② MA50 > MA150
|
||||
③ MA150 > MA200
|
||||
④ close > MA200
|
||||
⑤ close ≥ min(close[-252:]) × 1.25 # Stage 2 진입
|
||||
debug: 각 조건 boolean
|
||||
```
|
||||
|
||||
### 5.8 VCP-lite (변동성 수축률) — `VcpLite`
|
||||
|
||||
```text
|
||||
params: short_window = 40, long_window = 252 # 8주 / 52주
|
||||
daily_range_pct = (high - low) / close
|
||||
short_vol = mean(daily_range_pct[-40:])
|
||||
long_vol = mean(daily_range_pct[-252:])
|
||||
raw = 1 - (short_vol / long_vol) # 양수면 수축
|
||||
score = percentile_rank(raw, 통과군) × 100
|
||||
debug: short_vol, long_vol, contraction_ratio
|
||||
주: 진짜 미너비니 VCP(베이스 카운트·피벗 포인트)는 후속 슬라이스
|
||||
```
|
||||
|
||||
### 5.9 결합 (`engine.combine`)
|
||||
|
||||
```python
|
||||
total = Σ(w[n] * scores[n]) / Σ(w[n]) # active 노드만
|
||||
# 가중치 0 → 노드 실행 스킵. 모든 가중치 0이면 422 에러.
|
||||
```
|
||||
|
||||
### 5.10 디폴트 가중치
|
||||
|
||||
| 노드 | w | 근거 |
|
||||
|------|----|------|
|
||||
| foreign_buy | 1.0 | 한국 시장 강한 시그널 |
|
||||
| volume_surge | 1.0 | 표준 |
|
||||
| momentum | 1.0 | 표준 |
|
||||
| high52w | **1.2** | 미너비니 SEPA 핵심 |
|
||||
| rs_rating | **1.2** | 미너비니 + IBD 핵심 |
|
||||
| ma_alignment | 1.0 | Stage 2 확인용 |
|
||||
| vcp_lite | 0.8 | 단순 버전이라 보수적 가중 |
|
||||
|
||||
### 5.11 포지션 사이징 — `position_sizer.py`
|
||||
|
||||
```text
|
||||
params (settings):
|
||||
atr_window = 14
|
||||
atr_stop_mult = 2.0 # 2 × ATR 손절
|
||||
rr_ratio = 2.0 # 익절 = 진입가 + 2R
|
||||
|
||||
atr14 = ATR_Wilder(high, low, close, 14) # Wilder's smoothing (RMA), Pandas .ewm(alpha=1/14)
|
||||
entry = round_won(close × 1.005) # 다음날 시초 0.5% 위
|
||||
stop = round_won(close - 2.0 × atr14)
|
||||
target = round_won(entry + 2.0 × (entry - stop))
|
||||
r_pct = (entry - stop) / entry × 100 # 손실 위험 %
|
||||
|
||||
# round_won(x) = int(round(x)) — 1원 단위 반올림 (Python builtin)
|
||||
```
|
||||
|
||||
ATR은 **Wilder's smoothing** (RMA). 일반 SMA보다 트레이딩 표준. MVP는 1원 단위 라운딩. KRX 호가단위(1·5·10·50·100·500·1000원)는 후속.
|
||||
|
||||
### 5.12 정규화 시 주의점
|
||||
|
||||
- 게이트 통과군이 100종목 미만이면 백분위 의미 ↓. 응답 `warnings`에 경고.
|
||||
- 데이터 부족(상장 60일 미만 등)으로 NaN 발생 시 자동 0점 처리 (게이트가 이미 걸러줄 것).
|
||||
|
||||
---
|
||||
|
||||
## 6. API 명세 (prefix `/api/stock/screener/*`)
|
||||
|
||||
### 6.1 엔드포인트 표
|
||||
|
||||
| 메서드 | 경로 | 호출 주체 | 책임 |
|
||||
|--------|------|----------|------|
|
||||
| GET | `/nodes` | 프론트 | 노드 메타데이터 (label, default_params, param_schema) |
|
||||
| GET | `/settings` | 프론트 | 현재 설정 조회 |
|
||||
| PUT | `/settings` | 프론트 | 설정 업서트 (id=1 싱글톤) |
|
||||
| POST | `/run` | 프론트 · agent-office | 분석 1회 실행. mode 매트릭스로 분기 |
|
||||
| POST | `/snapshot/refresh` | agent-office | KRX 캐시 강제 갱신 |
|
||||
| GET | `/runs?limit=30` | 프론트 | 최근 실행 메타 리스트 |
|
||||
| GET | `/runs/{id}` | 프론트 | 특정 실행 결과 전체 |
|
||||
|
||||
### 6.2 `/run` 시맨틱
|
||||
|
||||
```jsonc
|
||||
// REQUEST
|
||||
POST /api/stock/screener/run
|
||||
{
|
||||
"mode": "preview" | "manual_save" | "auto",
|
||||
"asof": "2026-05-12", // 생략 시 직전 거래일
|
||||
"weights": { ... }, // optional override
|
||||
"node_params": { ... }, // optional override
|
||||
"gate_params": { ... }, // optional override
|
||||
"top_n": 20 // optional override
|
||||
}
|
||||
|
||||
// RESPONSE
|
||||
{
|
||||
"asof": "2026-05-12",
|
||||
"mode": "preview",
|
||||
"status": "success",
|
||||
"run_id": null, // manual_save·auto만
|
||||
"survivors_count": 612,
|
||||
"weights": { ... }, // 실제 사용된 값
|
||||
"top_n": 20,
|
||||
"results": [
|
||||
{
|
||||
"rank": 1,
|
||||
"ticker": "005930",
|
||||
"name": "삼성전자",
|
||||
"total_score": 84.3,
|
||||
"scores": {
|
||||
"foreign_buy": 92, "volume_surge": 78, "momentum": 73,
|
||||
"high52w": 88, "rs_rating": 95, "ma_alignment": 80, "vcp_lite": 70
|
||||
},
|
||||
"close": 74500,
|
||||
"market_cap": 444800000000000,
|
||||
"entry_price": 74872,
|
||||
"stop_price": 71200,
|
||||
"target_price": 82216,
|
||||
"atr14": 1835.5,
|
||||
"r_pct": 4.9
|
||||
}
|
||||
],
|
||||
"telegram_payload": null, // auto · manual_save만
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 mode 매트릭스
|
||||
|
||||
| mode | settings_override | DB 저장 | telegram_payload 반환 | telegram 실전송 |
|
||||
|------|------------------|---------|----------------------|----------------|
|
||||
| `preview` | 허용 (DB 미반영) | ❌ | ✅ (미리보기 표시용) | ❌ |
|
||||
| `manual_save` | 허용 (DB 미반영) | ✅ | ✅ | ❌ |
|
||||
| `auto` | 무시 (DB settings만) | ✅ | ✅ | ✅ (호출자=agent-office) |
|
||||
|
||||
`telegram_payload`는 `status='success'`일 때 항상 빌드해 반환 (페이로드 1회 생성 비용 매우 작음). **실전송은 mode='auto' 시 호출자(agent-office) 책임**. `status='failed'`·`'skipped_holiday'`이면 `null`.
|
||||
|
||||
### 6.4 `asof` 처리
|
||||
|
||||
- 요청에 `asof` 없으면: stock-lab이 `holidays.json` 참조해 **직전 거래일**로 자동 설정
|
||||
- 요청한 `asof`가 공휴일·주말이거나 캐시에 없으면: 503 + message "no snapshot for {asof}"
|
||||
- `agent-office` 자동 잡이 공휴일에 호출하는 경우 stock-lab은 status='skipped_holiday'로 success 응답 (텔레그램 전송 안 함)
|
||||
|
||||
### 6.5 에러 응답
|
||||
|
||||
응답 body의 `status` 필드와 HTTP status 코드의 매핑:
|
||||
|
||||
| HTTP | body.status | 발생 |
|
||||
|------|-------------|------|
|
||||
| 200 | `success` | 정상 분석 완료 |
|
||||
| 200 | `skipped_holiday` | 공휴일·주말 asof로 자동 잡이 호출됨 |
|
||||
| 422 | `failed` | 가중치 합 0, 게이트 통과 0, 잘못된 asof 형식 |
|
||||
| 503 | `failed` | 캐시 미존재 (snapshot 미실행) |
|
||||
| 500 | `failed` | 예기치 못한 예외 (응답 body는 일반 메시지) |
|
||||
|
||||
---
|
||||
|
||||
## 7. 프론트엔드 구조 (web-ui)
|
||||
|
||||
### 7.1 라우팅 & 내비게이션
|
||||
|
||||
- `src/routes.jsx`: `/stock/screener` 등록, 라벨 "스크리너"
|
||||
- `src/Router.jsx`: 라우트 추가
|
||||
- Stock·StockTrade 페이지 상단에 "스크리너" 링크
|
||||
- 홈(`/`) 허브 카드에 항목 추가
|
||||
|
||||
### 7.2 디렉토리
|
||||
|
||||
```
|
||||
src/pages/stock/screener/
|
||||
├─ Screener.jsx # 페이지 루트
|
||||
├─ Screener.css
|
||||
├─ components/
|
||||
│ ├─ NodePanel.jsx # 점수 노드 7개 카드
|
||||
│ ├─ NodeCard.jsx # param_schema 기반 자동 폼
|
||||
│ ├─ GatePanel.jsx # 위생 게이트 1개
|
||||
│ ├─ GlobalControls.jsx # Top N, ATR, RR, "지금 실행", "스냅샷 저장"
|
||||
│ ├─ ResultTable.jsx
|
||||
│ ├─ ScoreChips.jsx # 각 노드 점수 칩
|
||||
│ ├─ RunHistoryList.jsx
|
||||
│ └─ TelegramPreview.jsx
|
||||
└─ hooks/
|
||||
├─ useScreenerMeta.js
|
||||
├─ useScreenerSettings.js
|
||||
├─ useScreenerRun.js
|
||||
└─ useScreenerHistory.js
|
||||
```
|
||||
|
||||
### 7.3 `src/api.js` 신규 헬퍼
|
||||
|
||||
```js
|
||||
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
|
||||
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
|
||||
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
|
||||
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
|
||||
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
|
||||
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||
```
|
||||
|
||||
### 7.4 레이아웃
|
||||
|
||||
```
|
||||
PC (≥1024px)
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 헤더 — 분석 기준일 · 직전 자동 잡 시각 · "스냅샷 저장" │
|
||||
├──────────────┬──────────────────────────────┬───────────────┤
|
||||
│ NodePanel │ ResultTable │ RunHistoryList │
|
||||
│ + GlobalControls │ TelegramPreview │ │
|
||||
│ [지금 실행] │ │ │
|
||||
└──────────────┴──────────────────────────────┴───────────────┘
|
||||
|
||||
모바일 (<768px) — 세로 적층
|
||||
[헤더] → [NodePanel 접기] → [GlobalControls+실행] → [ResultTable 가로 스크롤]
|
||||
→ [TelegramPreview 접기] → [RunHistoryList]
|
||||
```
|
||||
|
||||
### 7.5 상태 관리 패턴
|
||||
|
||||
- `useScreenerMeta`: 마운트 시 1회, 정적
|
||||
- `useScreenerSettings`: GET → 사용자 슬라이더 조작 시 로컬 dirty state. **명시적 "설정 저장" 버튼**에서만 PUT
|
||||
- "지금 실행" → `runScreener({mode:'preview', ...override})`. **DB는 건드리지 않음**
|
||||
- "스냅샷 저장" → 같은 override를 `mode:'manual_save'`로 재호출
|
||||
- 히스토리 클릭 → `getScreenerRun(id)`로 결과 표 교체
|
||||
|
||||
---
|
||||
|
||||
## 8. 텔레그램 메시지 포맷
|
||||
|
||||
자동 잡과 manual_save 모두 동일. **Top 20 중 본문 1-10**까지 표시, 11-20은 페이지 링크. MarkdownV2.
|
||||
|
||||
```
|
||||
🎯 *KRX 강세주 스크리너* — 2026-05-12 (자동)
|
||||
통과 612종 / Top 20 / 본문 1-10
|
||||
|
||||
1. *삼성전자* `005930` ⭐ 84.3
|
||||
👤외 ⚡거 🚀모 🆙고 💪RS 📈MA
|
||||
진입 74,872 손절 71,200 익절 82,216 (R 4.9%)
|
||||
|
||||
2. *NAVER* `035420` ⭐ 81.7
|
||||
👤외 ⚡거 🆙고 💪RS 📈MA
|
||||
진입 215,400 손절 207,800 익절 230,600 (R 3.5%)
|
||||
|
||||
⋯ (3-10)
|
||||
|
||||
🔗 전체 결과·11~20위:
|
||||
https://gahusb.synology.me/stock/screener?run_id=42
|
||||
```
|
||||
|
||||
### 노드 아이콘 (점수 ≥70인 노드만 표시)
|
||||
|
||||
| 노드 | 아이콘 |
|
||||
|------|--------|
|
||||
| foreign_buy | 👤외 |
|
||||
| volume_surge | ⚡거 |
|
||||
| momentum | 🚀모 |
|
||||
| high52w | 🆙고 |
|
||||
| rs_rating | 💪RS |
|
||||
| ma_alignment | 📈MA |
|
||||
| vcp_lite | 🌀VCP |
|
||||
|
||||
빌더(`screener/telegram.py`)는 payload만 반환:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"chat_target": "default",
|
||||
"parse_mode": "MarkdownV2",
|
||||
"text": "..." // 위 메시지
|
||||
}
|
||||
```
|
||||
|
||||
agent-office가 받아서 자체 텔레그램 채널로 발신. stock-lab은 텔레그램 SDK 의존성 없음.
|
||||
|
||||
---
|
||||
|
||||
## 9. agent-office 통합
|
||||
|
||||
agent-office 측에 새 잡(또는 stock_agent 액션) 추가:
|
||||
|
||||
```text
|
||||
Trigger: 평일 16:30 KST (Asia/Seoul)
|
||||
Steps:
|
||||
1. POST /api/stock/screener/snapshot/refresh
|
||||
실패해도 다음 단계 진행 (이전 캐시로 분석)
|
||||
2. POST /api/stock/screener/run { "mode": "auto" }
|
||||
3. 응답에서 status 확인:
|
||||
- status == 'skipped_holiday': 종료, 텔레그램 미발신
|
||||
- status == 'success': telegram_payload 추출 → 발신
|
||||
- status == 'failed': agent-office 자체 알림(기존 패턴)으로 운영자에게
|
||||
4. 텔레그램 발신은 agent-office의 기존 채널 사용
|
||||
```
|
||||
|
||||
**공휴일 판정은 stock-lab 책임** (`holidays.json`이 stock-lab에 있으므로). agent-office는 매 평일 16:30에 호출하고 응답 status로 분기. agent-office에 공휴일 데이터를 복제할 필요 없음.
|
||||
|
||||
stock-lab은 agent-office의 인증을 신뢰 (내부 Docker 네트워크). MVP에서 헤더 토큰 검증 없음. 후속에서 필요해지면 시크릿 헤더 추가.
|
||||
|
||||
---
|
||||
|
||||
## 10. 에러 처리
|
||||
|
||||
| 발생 지점 | 정책 |
|
||||
|----------|------|
|
||||
| pykrx 종목 단위 실패 | retry ×3 → 실패해도 다음 종목 계속. 전체 실패율 >20%면 snapshot 작업 자체 실패 |
|
||||
| 캐시 미존재 (`asof` 데이터 없음) | 503 + message "snapshot not available for {asof}" |
|
||||
| 노드 1개 compute 실패 | 해당 노드 점수 0 처리, 다른 노드 정상. 응답 `warnings`에 사유 |
|
||||
| 게이트 통과 종목 0 | 422 + message "no survivors after hygiene gate" |
|
||||
| 모든 가중치 0 | 422 + message "no active score nodes" |
|
||||
| 텔레그램 전송 실패 | `/run` 응답 status는 success. agent-office 측 로그·재시도 |
|
||||
| 예기치 못한 예외 | 500. 스택트레이스는 stock-lab stdout 로그에만. 응답은 일반 메시지 |
|
||||
|
||||
`/run`의 `warnings` 필드는 치명적이지 않은 이상을 모음. 프론트는 결과 표 위에 노란 배너로 노출.
|
||||
|
||||
---
|
||||
|
||||
## 11. 테스트 전략
|
||||
|
||||
stock-lab의 평탄 pytest 컨벤션을 따름. `app/test_screener_*.py`로 통합.
|
||||
|
||||
### 11.1 단위 테스트 (노드별)
|
||||
|
||||
```
|
||||
app/test_screener_nodes_foreign_buy.py
|
||||
app/test_screener_nodes_volume_surge.py
|
||||
app/test_screener_nodes_momentum.py
|
||||
app/test_screener_nodes_high52w.py
|
||||
app/test_screener_nodes_rs_rating.py
|
||||
app/test_screener_nodes_ma_alignment.py
|
||||
app/test_screener_nodes_vcp_lite.py
|
||||
app/test_screener_nodes_hygiene.py
|
||||
app/test_screener_position_sizer.py
|
||||
```
|
||||
|
||||
**공통 케이스**:
|
||||
|
||||
1. 알려진 입력 → 알려진 출력 (회귀 방지)
|
||||
2. 데이터 부족(상장 30일짜리) → 게이트 탈락 또는 NaN 안전
|
||||
3. 모든 종목 동일 값 → 백분위 정규화가 50점으로 평탄화
|
||||
4. 극값 1개 → 다른 종목 점수가 무너지지 않음 (특히 volume_surge의 log1p)
|
||||
|
||||
### 11.2 통합 테스트
|
||||
|
||||
```
|
||||
app/test_screener_engine.py # combine, Screener.run, ScreenContext.restrict
|
||||
app/test_screener_router.py # /run mode 매트릭스, /settings round-trip, /nodes, /runs
|
||||
app/test_screener_telegram.py # 메시지 텍스트 생성
|
||||
```
|
||||
|
||||
### 11.3 픽스쳐
|
||||
|
||||
`app/screener/_test_fixtures.py`:
|
||||
- 5종목 × 60거래일 합성 DataFrame 빌더
|
||||
- 시나리오: "강세주 1종", "위생 게이트 탈락 1종(시총 부족)", "데이터 부족 1종", "약세주 2종"
|
||||
- `StubScreenContext`: DB 거치지 않고 메모리 DataFrame 주입
|
||||
|
||||
### 11.4 수동 검증 (verification-before-completion)
|
||||
|
||||
- 실 KRX 데이터로 1회 돌려 Top 20이 합리적인 강세주 후보인지 사용자가 눈으로 확인
|
||||
- 자동 잡 1회 실행 후 텔레그램에 메시지 도착 확인
|
||||
- 모바일 화면에서 결과 표 가로 스크롤 OK 확인
|
||||
|
||||
---
|
||||
|
||||
## 12. 운영
|
||||
|
||||
- 로그: stock-lab stdout (Docker logs)
|
||||
- 알림: agent-office가 `/run` failed 응답을 받으면 텔레그램 자체 알림
|
||||
- 백업: stock.db는 NAS Synology 자체 백업 정책에 의존
|
||||
- 메트릭 대시보드: MVP 범위 밖 (후속 슬라이스)
|
||||
|
||||
---
|
||||
|
||||
## 13. 양쪽 동시 수정 체크리스트 (workspace CLAUDE.md 규약)
|
||||
|
||||
- [ ] 백엔드: `web-backend/stock-lab/app/screener/` 패키지 신규
|
||||
- [ ] 백엔드: `app/main.py`에 router include
|
||||
- [ ] 백엔드: stock.db에 신규 테이블 7개 `ensure_*_schema()` 함수
|
||||
- [ ] 백엔드: `requirements.txt`에 `pykrx` 추가
|
||||
- [ ] 프론트: `src/api.js`에 7개 헬퍼 추가
|
||||
- [ ] 프론트: `src/routes.jsx` + `src/Router.jsx`에 `/stock/screener` 등록
|
||||
- [ ] 프론트: `src/pages/stock/screener/` 디렉토리 신규
|
||||
- [ ] 프론트: `web-ui/CLAUDE.md` API 테이블에 7개 엔드포인트 추가
|
||||
- [ ] agent-office: 평일 16:30 KST `stock_agent screener` 잡 추가
|
||||
- [ ] 배포: `scripts/deploy.bat` 또는 개별
|
||||
|
||||
---
|
||||
|
||||
## 14. 후속 슬라이스 예약
|
||||
|
||||
| # | 슬라이스 | 의존 |
|
||||
|---|---------|------|
|
||||
| 2 | AI 뉴스 호재/악재 노드 | agent-office LLM 사용량 설계 |
|
||||
| 3 | 노드 캔버스 UI (react-flow) | MVP 노드 인터페이스 안정화 후 |
|
||||
| 4 | 주간 자가학습 (가중치 자동 조정 제안) | screener_runs 누적 4주 이상 |
|
||||
| 5 | DART 공시·재무제표 노드 | DART 수집 파이프라인 별도 spec |
|
||||
| 6 | 분봉 기반 노드 | 한투 API 분봉 캐싱 |
|
||||
| 7 | 진짜 미너비니 VCP | 베이스 카운트·피벗 포인트 정의 |
|
||||
| 8 | 멀티 프리셋 | settings 테이블 확장 |
|
||||
| 9 | 백테스트 화면 | screener_runs + krx_daily_prices join |
|
||||
| 10 | KRX 호가단위 적용 | 포지션 사이저 후처리 |
|
||||
|
||||
---
|
||||
|
||||
## 부록 A — 노드 메타데이터 응답 예시 (`GET /nodes`)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"score_nodes": [
|
||||
{
|
||||
"name": "foreign_buy",
|
||||
"label": "외국인 누적 순매수",
|
||||
"default_params": { "window_days": 5 },
|
||||
"param_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"window_days": { "type": "integer", "minimum": 1, "maximum": 60, "default": 5 }
|
||||
}
|
||||
}
|
||||
}
|
||||
// … 7개
|
||||
],
|
||||
"gate_nodes": [
|
||||
{
|
||||
"name": "hygiene",
|
||||
"label": "위생 게이트",
|
||||
"default_params": {
|
||||
"min_market_cap_won": 50000000000,
|
||||
"min_avg_value_won": 500000000,
|
||||
"min_listed_days": 60,
|
||||
"skip_managed": true,
|
||||
"skip_preferred": true,
|
||||
"skip_spac": true,
|
||||
"skip_halted_days": 3
|
||||
},
|
||||
"param_schema": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
이 응답으로 프론트는 `NodeCard`를 자동 생성합니다. 새 노드 추가 시 백엔드 클래스 1개 + registry 등록 1줄만으로 UI에 자동 노출.
|
||||
@@ -20,8 +20,11 @@ if (fs.existsSync(envLocalPath)) {
|
||||
const isWin = process.platform === "win32";
|
||||
const isMac = process.platform === "darwin";
|
||||
const src = "dist";
|
||||
const dstWin = "Z:\\docker\\webpage\\frontend\\";
|
||||
const dstMac = "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
|
||||
// Windows 배포 경로 — Z: 매핑이 NAS 루트(/volume1/)인 경우 docker\webpage\frontend,
|
||||
// /volume1/docker/만 매핑된 경우 webpage\frontend, /volume1/docker/webpage 매핑이면 frontend.
|
||||
// NAS_FRONTEND_DEST_WIN env로 override (예: "Z:\\webpage\\frontend\\")
|
||||
const dstWin = process.env.NAS_FRONTEND_DEST_WIN || "Z:\\docker\\webpage\\frontend\\";
|
||||
const dstMac = process.env.NAS_FRONTEND_DEST_MAC || "/Volumes/gahusb.synology.me/docker/webpage/frontend/";
|
||||
const dst = isWin ? dstWin : dstMac;
|
||||
|
||||
if (!fs.existsSync(src)) {
|
||||
@@ -30,8 +33,10 @@ if (!fs.existsSync(src)) {
|
||||
}
|
||||
|
||||
if (isWin) {
|
||||
// dstWin을 PowerShell 문자열로 안전하게 escape
|
||||
const dstPs = dstWin.replace(/\\/g, "\\\\");
|
||||
const cmd =
|
||||
'powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"Z:\\\\docker\\\\webpage\\\\frontend\\\\\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS drive not found. Check Z: mapping.\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"';
|
||||
`powershell -NoProfile -ExecutionPolicy Bypass -Command "$ErrorActionPreference=\\"Stop\\"; $src=\\"dist\\"; $dst=\\"${dstPs}\\"; if(!(Test-Path $src)){ throw \\"dist not found. Run build first.\\" }; if(!(Test-Path $dst)){ throw \\"NAS 경로를 찾을 수 없음: $dst — Z: 매핑 또는 NAS_FRONTEND_DEST_WIN env 확인\\" }; $log = Join-Path (Get-Location) \\"robocopy.log\\"; robocopy $src $dst /MIR /R:1 /W:1 /E /NFL /NDL /NP /V /TEE /LOG:$log; $rc = $LASTEXITCODE; if($rc -ge 8){ Write-Host \\"robocopy failed with code $rc. See $log\\"; exit $rc } else { exit 0 }"`;
|
||||
execSync(cmd, { stdio: "inherit" });
|
||||
} else if (isMac) {
|
||||
const sshTarget = process.env.NAS_SSH_TARGET;
|
||||
|
||||
30
src/api.js
30
src/api.js
@@ -674,3 +674,33 @@ export const getYoutubeAuthUrl = () => apiGet('/api/music/youtub
|
||||
export const getYoutubeStatus = () => apiGet('/api/music/youtube/status');
|
||||
export const disconnectYoutube = () => apiPost('/api/music/youtube/disconnect');
|
||||
|
||||
// === Batch generation ===
|
||||
export const startBatchGen = (payload) => apiPost('/api/music/generate-batch', payload);
|
||||
export const getBatchJob = (id) => apiGet(`/api/music/generate-batch/${id}`);
|
||||
export const listBatchJobs = (status='all') => apiGet(`/api/music/generate-batch?status=${status}`);
|
||||
export const listGenres = () => apiGet('/api/music/genres');
|
||||
|
||||
// === 주간 회고 (weekly_review) ===
|
||||
// apiGet은 비-2xx 응답에서 `HTTP <status> ...` 메시지로 Error를 throw 하므로
|
||||
// 404 케이스는 메시지를 파싱하여 null로 변환한다.
|
||||
export const getLatestReview = () => apiGet('/api/lotto/review/latest').catch(e => {
|
||||
if (e?.status === 404 || /^HTTP 404\b/.test(e?.message || '')) return null;
|
||||
throw e;
|
||||
});
|
||||
|
||||
export const getReviewHistory = (limit = 4) =>
|
||||
apiGet(`/api/lotto/review/history?limit=${limit}`).then(d => d.reviews || []);
|
||||
|
||||
// === 큐레이터 4계층 원클릭 구매 ===
|
||||
export const bulkPurchase = ({ draw_no, tier_mode, sets, amount }) =>
|
||||
apiPost('/api/lotto/purchase/bulk', { draw_no, tier_mode, sets, amount });
|
||||
|
||||
// ---- Stock Screener ----
|
||||
export const getScreenerNodes = () => apiGet ('/api/stock/screener/nodes');
|
||||
export const getScreenerSettings = () => apiGet ('/api/stock/screener/settings');
|
||||
export const saveScreenerSettings = (body) => apiPut ('/api/stock/screener/settings', body);
|
||||
export const runScreener = (body) => apiPost('/api/stock/screener/run', body);
|
||||
export const refreshScreenerSnap = () => apiPost('/api/stock/screener/snapshot/refresh');
|
||||
export const listScreenerRuns = (limit = 30) => apiGet (`/api/stock/screener/runs?limit=${limit}`);
|
||||
export const getScreenerRun = (id) => apiGet (`/api/stock/screener/runs/${id}`);
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import SwipeableView from '../../components/SwipeableView';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'briefing', label: '🗓 이번 주 브리핑' },
|
||||
{ id: 'analysis', label: '📊 분석·통계' },
|
||||
{ id: 'analysis', label: '📚 자료실 / Deep Dive' },
|
||||
{ id: 'purchase', label: '💰 구매·성과' },
|
||||
];
|
||||
|
||||
|
||||
@@ -1020,7 +1020,7 @@
|
||||
|
||||
.lotto-purchase-list__head {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
font-size: 11px;
|
||||
@@ -1033,7 +1033,7 @@
|
||||
|
||||
.lotto-purchase-row {
|
||||
display: grid;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 1fr) 120px;
|
||||
grid-template-columns: 60px 100px 100px 100px minmax(0, 160px) minmax(0, 1fr) 120px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
@@ -1068,6 +1068,21 @@
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.lotto-purchase-row__hits {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hit-badge { display: inline-block; min-width: 16px; padding: 1px 4px; margin-right: 2px;
|
||||
font-size: 10px; border-radius: 4px; background: rgba(255,255,255,0.06); text-align: center; }
|
||||
.hit-badge.hit-3 { background: rgba(80, 200, 120, 0.2); color: #76e09a; }
|
||||
.hit-badge.hit-4 { background: rgba(255, 200, 80, 0.25); color: #ffce6e; font-weight: 700; }
|
||||
.hit-badge.hit-5, .hit-badge.hit-6 { background: rgba(255, 100, 130, 0.3); color: #ff8aa0; font-weight: 700; }
|
||||
.prize-flag { font-size: 10px; color: #ff8aa0; margin-left: 6px; }
|
||||
|
||||
.is-pos { color: #97c9aa; }
|
||||
.is-neg { color: #f7a8a5; }
|
||||
.is-prize { color: #fdd4b1; }
|
||||
@@ -1098,8 +1113,8 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+5),
|
||||
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+5) {
|
||||
.lotto-purchase-list__head span:nth-child(n+3):nth-child(-n+6),
|
||||
.lotto-purchase-row span:nth-child(n+3):nth-child(-n+6) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -1143,7 +1158,7 @@
|
||||
|
||||
.lotto-purchase-list__head,
|
||||
.lotto-purchase-row {
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 1fr) 100px;
|
||||
grid-template-columns: 56px 90px 90px minmax(0, 120px) minmax(0, 1fr) 100px;
|
||||
}
|
||||
|
||||
.lotto-purchase-list__head span:nth-child(4),
|
||||
@@ -1526,3 +1541,14 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
.lotto-section-fold { margin-bottom: 14px; }
|
||||
.lotto-section-fold > summary { cursor: pointer; padding: 12px 16px; background: rgba(255,255,255,0.03);
|
||||
border-radius: 10px; font-weight: 600; font-size: 14px; opacity: 0.85; }
|
||||
.lotto-section-fold[open] > summary { margin-bottom: 12px; opacity: 1; }
|
||||
|
||||
.trend-chart { display: block; margin: 0 auto; }
|
||||
.trend-legend { display: flex; gap: 16px; justify-content: center; font-size: 11px; opacity: 0.7; margin-top: 8px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; vertical-align: middle; }
|
||||
.dot--curator { background: #b8a8ff; }
|
||||
.dot--user { background: #76e09a; }
|
||||
|
||||
@@ -137,6 +137,7 @@ const PurchasePanel = ({
|
||||
<span>투자금</span>
|
||||
<span>당첨금</span>
|
||||
<span>손익</span>
|
||||
<span>채점</span>
|
||||
<span>메모</span>
|
||||
<span />
|
||||
</div>
|
||||
@@ -152,6 +153,14 @@ const PurchasePanel = ({
|
||||
<span className={net >= 0 ? 'is-pos' : 'is-neg'}>
|
||||
{net >= 0 ? '+' : ''}{fmtWon(net)}
|
||||
</span>
|
||||
<span className="lotto-purchase-row__hits">
|
||||
{(rec.results || []).map((r, i) => (
|
||||
<span key={i} className={`hit-badge hit-${r.correct}`}>{r.correct}</span>
|
||||
))}
|
||||
{(rec.results || []).some((r) => r.correct >= 4) && (
|
||||
<span className="prize-flag">🚨 4등↑ 확인 필요</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="lotto-purchase-row__note">{rec.note || '-'}</span>
|
||||
<div className="lotto-purchase-row__actions">
|
||||
<button className="button ghost small" onClick={() => onEditStart(rec)}>
|
||||
|
||||
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
44
src/pages/lotto/components/PurchaseTrendChart.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getReviewHistory } from '../../../api';
|
||||
|
||||
export default function PurchaseTrendChart() {
|
||||
const [reviews, setReviews] = useState([]);
|
||||
useEffect(() => {
|
||||
getReviewHistory(4).then(rs => setReviews(rs.reverse())); // asc
|
||||
}, []);
|
||||
|
||||
if (reviews.length === 0) return null;
|
||||
|
||||
const maxAvg = Math.max(
|
||||
...reviews.flatMap(r => [r.curator_avg_match || 0, r.user_avg_match || 0]),
|
||||
2.5
|
||||
);
|
||||
const w = 320, h = 80, pad = 16;
|
||||
const xs = (i) => pad + (i / Math.max(reviews.length - 1, 1)) * (w - 2 * pad);
|
||||
const ys = (v) => v == null ? null : h - pad - (v / maxAvg) * (h - 2 * pad);
|
||||
|
||||
const line = (key) => reviews
|
||||
.map((r, i) => ({ x: xs(i), y: ys(r[key]) }))
|
||||
.filter(p => p.y != null)
|
||||
.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`)
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Trend (last 4 weeks)</p>
|
||||
<h3>너 vs 큐레이터 평균 일치 수</h3>
|
||||
</div>
|
||||
</div>
|
||||
<svg width={w} height={h} className="trend-chart">
|
||||
<path d={line('curator_avg_match')} stroke="#b8a8ff" strokeWidth="2" fill="none" />
|
||||
<path d={line('user_avg_match')} stroke="#76e09a" strokeWidth="2" fill="none" />
|
||||
</svg>
|
||||
<div className="trend-legend">
|
||||
<span><span className="dot dot--curator" /> 큐레이터</span>
|
||||
<span><span className="dot dot--user" /> 너</span>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
33
src/pages/lotto/components/decision/BulkPurchaseButton.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
import { bulkPurchase } from '../../../../api';
|
||||
import { MODES } from './TierModeToggle';
|
||||
|
||||
export default function BulkPurchaseButton({ drawNo, tierMode, onSuccess }) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const mode = MODES.find(m => m.key === tierMode) || MODES[0];
|
||||
|
||||
const onClick = async () => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await bulkPurchase({
|
||||
draw_no: drawNo,
|
||||
tier_mode: tierMode,
|
||||
sets: mode.sets,
|
||||
amount: mode.amount,
|
||||
});
|
||||
onSuccess?.();
|
||||
alert(`${mode.sets}세트 구매 기록 완료!`);
|
||||
} catch (e) {
|
||||
alert(`구매 기록 실패: ${e?.message || e}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button className="lc-btn lc-btn--prim" onClick={onClick} disabled={busy || !drawNo}>
|
||||
{busy ? '저장 중...' : `이대로 ${mode.sets}세트 구매했음`}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
102
src/pages/lotto/components/decision/DecisionCard.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import RetrospectiveBox from './RetrospectiveBox';
|
||||
import TierModeToggle, { MODES } from './TierModeToggle';
|
||||
import TierSection from './TierSection';
|
||||
import BulkPurchaseButton from './BulkPurchaseButton';
|
||||
import './decision.css';
|
||||
|
||||
const TIER_CHAIN = {
|
||||
core: ['core'],
|
||||
core_bonus: ['core', 'bonus'],
|
||||
core_bonus_extended: ['core', 'bonus', 'extended'],
|
||||
full: ['core', 'bonus', 'extended', 'pool'],
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'lotto.tier_mode';
|
||||
|
||||
export default function DecisionCard({ briefing, review, onPurchaseSuccess }) {
|
||||
const [tierMode, setTierMode] = useState(() =>
|
||||
localStorage.getItem(STORAGE_KEY) || 'core'
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, tierMode);
|
||||
}, [tierMode]);
|
||||
|
||||
const visibleTiers = TIER_CHAIN[tierMode];
|
||||
|
||||
const totalSets = useMemo(
|
||||
() => visibleTiers.reduce((sum, t) => sum + (briefing?.picks?.[t]?.length || 0), 0),
|
||||
[briefing, visibleTiers]
|
||||
);
|
||||
|
||||
// 분배 칩 — 보이는 계층의 risk_tag 합산
|
||||
const balance = useMemo(() => {
|
||||
const acc = { '안정': 0, '균형': 0, '공격': 0 };
|
||||
for (const t of visibleTiers) {
|
||||
for (const p of (briefing?.picks?.[t] || [])) {
|
||||
if (acc[p.risk_tag] !== undefined) acc[p.risk_tag]++;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, [briefing, visibleTiers]);
|
||||
|
||||
if (!briefing) return null;
|
||||
|
||||
let cursor = 0;
|
||||
|
||||
return (
|
||||
<div className="lc-card">
|
||||
<header className="lc-head">
|
||||
<div>
|
||||
<p className="lc-eyebrow">Curator Briefing · {briefing.draw_no}회</p>
|
||||
<h3 className="lc-title">{briefing.narrative.headline}</h3>
|
||||
</div>
|
||||
<div className="lc-conf">
|
||||
<div className="lc-conf__num">{briefing.confidence}</div>
|
||||
<div className="lc-conf__lbl">CONFIDENCE</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<RetrospectiveBox briefing={briefing} review={review} />
|
||||
|
||||
<p className="lc-headline-3">
|
||||
{(briefing.narrative.summary_3lines || []).join(' · ')}
|
||||
</p>
|
||||
|
||||
<div className="lc-balance">
|
||||
<div className="lc-balance__chips">
|
||||
{balance['안정'] > 0 && <span className="lc-chip lc-chip--stable">안정 ×{balance['안정']}</span>}
|
||||
{balance['균형'] > 0 && <span className="lc-chip lc-chip--balance">균형 ×{balance['균형']}</span>}
|
||||
{balance['공격'] > 0 && <span className="lc-chip lc-chip--aggro">공격 ×{balance['공격']}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TierModeToggle value={tierMode} onChange={setTierMode} />
|
||||
|
||||
{visibleTiers.map(tier => {
|
||||
const picks = briefing.picks?.[tier] || [];
|
||||
const idxBase = cursor;
|
||||
cursor += picks.length;
|
||||
return (
|
||||
<TierSection
|
||||
key={tier}
|
||||
tier={tier}
|
||||
picks={picks}
|
||||
rationale={briefing.tier_rationale?.[tier]}
|
||||
indexBase={idxBase}
|
||||
totalSets={totalSets}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="lc-actions">
|
||||
<BulkPurchaseButton
|
||||
drawNo={briefing.draw_no}
|
||||
tierMode={tierMode}
|
||||
onSuccess={onPurchaseSuccess}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
19
src/pages/lotto/components/decision/PickCard.jsx
Normal file
@@ -0,0 +1,19 @@
|
||||
const ROLE_COLOR = { '안정': 'stable', '균형': 'balance', '공격': 'aggro' };
|
||||
|
||||
export default function PickCard({ pick, index, total }) {
|
||||
const role = pick.risk_tag;
|
||||
return (
|
||||
<div className="lc-set">
|
||||
<div className="lc-set__head">
|
||||
<span className={`lc-set__role lc-set__role--${ROLE_COLOR[role]}`}>● {role}</span>
|
||||
<span className="lc-set__idx">Set {index + 1} / {total}</span>
|
||||
</div>
|
||||
<div className="lc-balls">
|
||||
{pick.numbers.map(n => (
|
||||
<span key={n} className={`ball ball--${Math.ceil(n / 10)}`}>{n}</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="lc-set__reason">{pick.reason}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
11
src/pages/lotto/components/decision/RetrospectiveBox.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function RetrospectiveBox({ briefing, review }) {
|
||||
const retro = briefing?.narrative?.retrospective;
|
||||
if (!retro) return null;
|
||||
const drawNo = review?.draw_no ?? (briefing?.draw_no ? briefing.draw_no - 1 : null);
|
||||
return (
|
||||
<aside className="lc-retro">
|
||||
<p className="lc-retro__time">▸ 지난 주 {drawNo ? `${drawNo}회` : ''} 회고</p>
|
||||
<p className="lc-retro__body">{retro}</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
28
src/pages/lotto/components/decision/TierModeToggle.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
const MODES = [
|
||||
{ key: 'core', label: '코어', sets: 5, amount: 5000 },
|
||||
{ key: 'core_bonus', label: '+ 보너스', sets: 10, amount: 10000 },
|
||||
{ key: 'core_bonus_extended', label: '+ 확장', sets: 15, amount: 15000 },
|
||||
{ key: 'full', label: '+ 풀', sets: 20, amount: 20000 },
|
||||
];
|
||||
|
||||
export default function TierModeToggle({ value, onChange }) {
|
||||
return (
|
||||
<div className="lc-toggle" role="tablist">
|
||||
{MODES.map((m, i) => (
|
||||
<button
|
||||
key={m.key}
|
||||
role="tab"
|
||||
aria-selected={value === m.key}
|
||||
className={`lc-toggle__chip ${value === m.key ? 'is-active' : ''}`}
|
||||
onClick={() => onChange(m.key)}
|
||||
>
|
||||
<span className="lc-toggle__dots">{'●'.repeat(i + 1) + '○'.repeat(3 - i)}</span>
|
||||
<span className="lc-toggle__lbl">{m.label}</span>
|
||||
<span className="lc-toggle__sub">{m.sets}세트 · {m.amount.toLocaleString()}원</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { MODES };
|
||||
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
25
src/pages/lotto/components/decision/TierSection.jsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import PickCard from './PickCard';
|
||||
|
||||
const TIER_TITLE = {
|
||||
core: '코어 (필수, 5세트)',
|
||||
bonus: '보너스 (+5)',
|
||||
extended: '확장 (+5)',
|
||||
pool: '풀 (+5)',
|
||||
};
|
||||
|
||||
export default function TierSection({ tier, picks, rationale, indexBase = 0, totalSets }) {
|
||||
if (!picks?.length) return null;
|
||||
return (
|
||||
<section className={`lc-tier lc-tier--${tier}`}>
|
||||
<header className="lc-tier__head">
|
||||
<h4>{TIER_TITLE[tier]}</h4>
|
||||
{rationale && tier !== 'core' && (
|
||||
<p className="lc-tier__rationale">{rationale}</p>
|
||||
)}
|
||||
</header>
|
||||
{picks.map((p, i) => (
|
||||
<PickCard key={i} pick={p} index={indexBase + i} total={totalSets} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
52
src/pages/lotto/components/decision/decision.css
Normal file
52
src/pages/lotto/components/decision/decision.css
Normal file
@@ -0,0 +1,52 @@
|
||||
.lc-card { max-width: 720px; margin: 0 auto; background: linear-gradient(180deg, #161220 0%, #1a1426 100%);
|
||||
border: 1px solid rgba(255,255,255,0.08); border-radius: 16px; padding: 24px; color: #ece6f7; }
|
||||
.lc-head { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 14px; }
|
||||
.lc-eyebrow { font-size: 10px; letter-spacing: 2px; opacity: 0.5; text-transform: uppercase; margin: 0 0 4px; }
|
||||
.lc-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
|
||||
.lc-conf { display: flex; flex-direction: column; align-items: flex-end; }
|
||||
.lc-conf__num { font-family: 'Courier New', monospace; font-size: 28px; font-weight: 700; color: #b8a8ff; letter-spacing: -0.04em; }
|
||||
.lc-conf__lbl { font-size: 9px; letter-spacing: 1.5px; opacity: 0.55; }
|
||||
.lc-retro { background: rgba(184, 168, 255, 0.06); border-left: 2px solid rgba(184, 168, 255, 0.4);
|
||||
padding: 10px 14px; margin: 14px 0; border-radius: 4px; }
|
||||
.lc-retro__time { font-size: 9px; letter-spacing: 1.5px; color: #b8a8ff; opacity: 0.7; margin: 0 0 4px; }
|
||||
.lc-retro__body { font-size: 13px; line-height: 1.55; opacity: 0.85; margin: 0; }
|
||||
.lc-headline { font-size: 16px; font-weight: 600; line-height: 1.5; margin: 18px 0 4px; }
|
||||
.lc-headline-3 { font-size: 12px; opacity: 0.65; line-height: 1.55; margin: 0 0 18px; }
|
||||
.lc-balance { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px;
|
||||
background: rgba(255,255,255,0.03); border-radius: 8px; margin-bottom: 16px; font-size: 11px; }
|
||||
.lc-balance__chips { display: flex; gap: 8px; }
|
||||
.lc-chip { padding: 3px 8px; border-radius: 100px; font-weight: 600; font-size: 11px; }
|
||||
.lc-chip--stable { background: rgba(80, 200, 120, 0.15); color: #76e09a; }
|
||||
.lc-chip--balance { background: rgba(255, 200, 80, 0.15); color: #ffce6e; }
|
||||
.lc-chip--aggro { background: rgba(255, 100, 130, 0.15); color: #ff8aa0; }
|
||||
.lc-toggle { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 16px 0; }
|
||||
.lc-toggle__chip { padding: 10px 8px; background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.08);
|
||||
border-radius: 10px; color: #ece6f7; cursor: pointer; display: flex; flex-direction: column; gap: 4px; align-items: center; }
|
||||
.lc-toggle__chip.is-active { background: rgba(184, 168, 255, 0.15); border-color: rgba(184, 168, 255, 0.5); }
|
||||
.lc-toggle__dots { letter-spacing: 2px; font-size: 10px; opacity: 0.7; }
|
||||
.lc-toggle__lbl { font-size: 12px; font-weight: 600; }
|
||||
.lc-toggle__sub { font-size: 10px; opacity: 0.55; }
|
||||
.lc-tier { margin-bottom: 14px; }
|
||||
.lc-tier__head { padding: 8px 0; border-top: 1px dashed rgba(255,255,255,0.1); margin-bottom: 8px; }
|
||||
.lc-tier:first-of-type .lc-tier__head { border-top: none; }
|
||||
.lc-tier__head h4 { font-size: 12px; font-weight: 600; margin: 0 0 4px; opacity: 0.75; letter-spacing: 0.5px; }
|
||||
.lc-tier__rationale { font-size: 11px; opacity: 0.55; margin: 0; }
|
||||
.lc-set { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 12px;
|
||||
padding: 14px; margin-bottom: 10px; }
|
||||
.lc-set__head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
|
||||
.lc-set__role { font-size: 11px; font-weight: 600; letter-spacing: 0.5px; }
|
||||
.lc-set__role--stable { color: #76e09a; }
|
||||
.lc-set__role--balance { color: #ffce6e; }
|
||||
.lc-set__role--aggro { color: #ff8aa0; }
|
||||
.lc-set__idx { font-size: 10px; opacity: 0.4; }
|
||||
.lc-balls { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.lc-set__reason { font-size: 12px; opacity: 0.7; line-height: 1.45; margin: 0; }
|
||||
.lc-actions { display: flex; gap: 10px; margin-top: 18px; }
|
||||
.lc-btn { padding: 12px 16px; border-radius: 10px; border: none; font-weight: 600; cursor: pointer;
|
||||
font-size: 14px; min-width: 160px; }
|
||||
.lc-btn--prim { background: linear-gradient(135deg, #b8a8ff, #8a78db); color: #14101e; }
|
||||
.lc-btn--prim:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.lc-btn--ghost { background: transparent; border: 1px solid rgba(255,255,255,0.15); color: #ece6f7; }
|
||||
@media (max-width: 480px) {
|
||||
.lc-toggle { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { getLatestBriefing, triggerLottoCurate } from '../../../api';
|
||||
|
||||
const normalizePicks = (picks) => {
|
||||
if (Array.isArray(picks)) {
|
||||
return { core: picks, bonus: [], extended: [], pool: [] };
|
||||
}
|
||||
return {
|
||||
core: picks?.core || [],
|
||||
bonus: picks?.bonus || [],
|
||||
extended: picks?.extended || [],
|
||||
pool: picks?.pool || [],
|
||||
};
|
||||
};
|
||||
|
||||
export default function useBriefing() {
|
||||
const [briefing, setBriefing] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -12,7 +24,7 @@ export default function useBriefing() {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data = await getLatestBriefing();
|
||||
setBriefing(data);
|
||||
setBriefing(data ? { ...data, picks: normalizePicks(data.picks) } : data);
|
||||
} catch (e) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
@@ -33,7 +45,7 @@ export default function useBriefing() {
|
||||
try {
|
||||
const data = await getLatestBriefing();
|
||||
if (data && data.generated_at !== prevGen) {
|
||||
setBriefing(data);
|
||||
setBriefing({ ...data, picks: normalizePicks(data.picks) });
|
||||
setRegenerating(false);
|
||||
clearInterval(pollingRef.current);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getPurchases, getPurchaseStats, addPurchase, updatePurchase, deletePurchase,
|
||||
bulkPurchase as apiBulkPurchase,
|
||||
} from '../../../api';
|
||||
import { emptyPurchaseForm } from '../lottoUtils';
|
||||
|
||||
@@ -94,6 +95,12 @@ export default function usePurchases() {
|
||||
} catch { refreshPurchases(); }
|
||||
}, [refreshPurchases]);
|
||||
|
||||
const handleBulkPurchase = useCallback(async (params) => {
|
||||
const result = await apiBulkPurchase(params);
|
||||
await refreshPurchases();
|
||||
return result;
|
||||
}, [refreshPurchases]);
|
||||
|
||||
useEffect(() => { refreshPurchases(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
return {
|
||||
@@ -101,5 +108,6 @@ export default function usePurchases() {
|
||||
purchaseFormOpen, purchaseForm, purchaseFormSaving, purchaseFormError, purchaseEditId,
|
||||
handlePurchaseFormOpen, handlePurchaseFormClose, handlePurchaseFormChange,
|
||||
handlePurchaseFormSubmit, handlePurchaseEditStart, handlePurchaseDelete,
|
||||
handleBulkPurchase,
|
||||
};
|
||||
}
|
||||
|
||||
23
src/pages/lotto/hooks/useReview.js
Normal file
23
src/pages/lotto/hooks/useReview.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getLatestReview, getReviewHistory } from '../../../api';
|
||||
|
||||
export default function useReview() {
|
||||
const [latest, setLatest] = useState(null);
|
||||
const [history, setHistory] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancel = false;
|
||||
Promise.all([getLatestReview(), getReviewHistory(4)])
|
||||
.then(([l, h]) => {
|
||||
if (cancel) return;
|
||||
setLatest(l);
|
||||
setHistory(h);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => !cancel && setLoading(false));
|
||||
return () => { cancel = true; };
|
||||
}, []);
|
||||
|
||||
return { latest, history, loading };
|
||||
}
|
||||
@@ -40,18 +40,21 @@ export default function AnalysisTab() {
|
||||
<PerformanceBanner perf={ld.perfStats} />
|
||||
|
||||
{/* 종합 추론 번호 추천 */}
|
||||
<CombinedRecommendPanel
|
||||
combined={ld.combined}
|
||||
history={ld.combinedHistory}
|
||||
loading={ld.combinedLoading}
|
||||
histLoading={ld.combinedHistLoading}
|
||||
onRun={ld.runCombinedRecommend}
|
||||
onCopy={copyNumbers}
|
||||
/>
|
||||
<details className="lotto-section-fold">
|
||||
<summary>종합 추론 추천</summary>
|
||||
<CombinedRecommendPanel
|
||||
combined={ld.combined}
|
||||
history={ld.combinedHistory}
|
||||
loading={ld.combinedLoading}
|
||||
histLoading={ld.combinedHistLoading}
|
||||
onRun={ld.runCombinedRecommend}
|
||||
onCopy={copyNumbers}
|
||||
/>
|
||||
</details>
|
||||
|
||||
{/* 최신 회차 + 시뮬레이션 추천 */}
|
||||
<div className="lotto-grid">
|
||||
{/* Latest Draw */}
|
||||
{/* 최신 회차 */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>최신 회차</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
@@ -87,8 +90,11 @@ export default function AnalysisTab() {
|
||||
<p className="lotto-empty">최신 회차 데이터가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* Simulation Picks */}
|
||||
{/* Simulation Picks */}
|
||||
<details className="lotto-section-fold">
|
||||
<summary>시뮬레이션 추천</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
@@ -163,19 +169,24 @@ export default function AnalysisTab() {
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* 이번 주 공략 리포트 */}
|
||||
<ReportPanel
|
||||
report={ld.report}
|
||||
history={ld.reportHistory}
|
||||
loading={ld.reportLoading}
|
||||
onRefresh={ld.refreshReport}
|
||||
onSelectDrw={ld.loadSpecificReport}
|
||||
/>
|
||||
<details className="lotto-section-fold">
|
||||
<summary>이번 주 공략 리포트</summary>
|
||||
<ReportPanel
|
||||
report={ld.report}
|
||||
history={ld.reportHistory}
|
||||
loading={ld.reportLoading}
|
||||
onRefresh={ld.refreshReport}
|
||||
onSelectDrw={ld.loadSpecificReport}
|
||||
/>
|
||||
</details>
|
||||
|
||||
{/* 통계 분석 */}
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>통계 분석</summary>
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Analysis</p>
|
||||
@@ -237,9 +248,12 @@ export default function AnalysisTab() {
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 전체 번호 분포 */}
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>전체 회차 번호 분포</summary>
|
||||
<section className="lotto-panel lotto-panel--wide">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Distribution</p>
|
||||
@@ -263,12 +277,18 @@ export default function AnalysisTab() {
|
||||
<p className="lotto-empty">통계 데이터를 불러오지 못했습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 내 번호 패턴 */}
|
||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||
<details className="lotto-section-fold">
|
||||
<summary>내 번호 패턴</summary>
|
||||
<PersonalAnalysisPanel data={ld.personalAnalysis} loading={ld.personalLoading} />
|
||||
</details>
|
||||
|
||||
{/* 수동 추천 */}
|
||||
<section className="lotto-panel">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>수동 추천</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">Manual Recommendation</p>
|
||||
@@ -365,9 +385,12 @@ export default function AnalysisTab() {
|
||||
<p className="lotto-empty">아직 추천 결과가 없습니다.</p>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
|
||||
{/* 추천 히스토리 */}
|
||||
<section className="lotto-panel">
|
||||
<details className="lotto-section-fold">
|
||||
<summary>추천 히스토리</summary>
|
||||
<section className="lotto-panel">
|
||||
<div className="lotto-panel__head">
|
||||
<div>
|
||||
<p className="lotto-panel__eyebrow">History</p>
|
||||
@@ -423,6 +446,7 @@ export default function AnalysisTab() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import useBriefing from '../hooks/useBriefing';
|
||||
import BriefingHeader from '../components/briefing/BriefingHeader';
|
||||
import BriefingSummary from '../components/briefing/BriefingSummary';
|
||||
import PickSetCard from '../components/briefing/PickSetCard';
|
||||
import useReview from '../hooks/useReview';
|
||||
import DecisionCard from '../components/decision/DecisionCard';
|
||||
import BriefingEmpty from '../components/briefing/BriefingEmpty';
|
||||
import CuratorUsageFooter from '../components/briefing/CuratorUsageFooter';
|
||||
|
||||
export default function BriefingTab() {
|
||||
const { briefing, loading, error, regenerating, regenerate } = useBriefing();
|
||||
const { latest: review } = useReview();
|
||||
|
||||
if (loading) return <div className="briefing-empty"><p>로딩 중...</p></div>;
|
||||
if (!briefing) return <BriefingEmpty regenerating={regenerating} onRegenerate={regenerate} error={error} />;
|
||||
|
||||
return (
|
||||
<div className="briefing-tab">
|
||||
<BriefingHeader briefing={briefing} regenerating={regenerating} onRegenerate={regenerate} />
|
||||
<BriefingSummary narrative={briefing.narrative} />
|
||||
<div className="briefing-picks">
|
||||
<h3>이번 주 5세트</h3>
|
||||
{briefing.picks.map((p, i) => <PickSetCard key={i} pick={p} index={i} />)}
|
||||
</div>
|
||||
<CuratorUsageFooter />
|
||||
<DecisionCard briefing={briefing} review={review} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import usePurchases from '../hooks/usePurchases';
|
||||
import PurchasePanel from '../components/PurchasePanel';
|
||||
import PurchaseTrendChart from '../components/PurchaseTrendChart';
|
||||
|
||||
export default function PurchaseTab() {
|
||||
const pur = usePurchases();
|
||||
|
||||
return (
|
||||
<PurchasePanel
|
||||
records={pur.purchases}
|
||||
stats={pur.purchaseStats}
|
||||
loading={pur.purchaseLoading}
|
||||
formOpen={pur.purchaseFormOpen}
|
||||
form={pur.purchaseForm}
|
||||
formSaving={pur.purchaseFormSaving}
|
||||
formError={pur.purchaseFormError}
|
||||
editId={pur.purchaseEditId}
|
||||
onFormOpen={pur.handlePurchaseFormOpen}
|
||||
onFormClose={pur.handlePurchaseFormClose}
|
||||
onFormChange={pur.handlePurchaseFormChange}
|
||||
onFormSubmit={pur.handlePurchaseFormSubmit}
|
||||
onEditStart={pur.handlePurchaseEditStart}
|
||||
onDelete={pur.handlePurchaseDelete}
|
||||
/>
|
||||
<>
|
||||
<PurchaseTrendChart />
|
||||
<PurchasePanel
|
||||
records={pur.purchases}
|
||||
stats={pur.purchaseStats}
|
||||
loading={pur.purchaseLoading}
|
||||
formOpen={pur.purchaseFormOpen}
|
||||
form={pur.purchaseForm}
|
||||
formSaving={pur.purchaseFormSaving}
|
||||
formError={pur.purchaseFormError}
|
||||
editId={pur.purchaseEditId}
|
||||
onFormOpen={pur.handlePurchaseFormOpen}
|
||||
onFormClose={pur.handlePurchaseFormClose}
|
||||
onFormChange={pur.handlePurchaseFormChange}
|
||||
onFormSubmit={pur.handlePurchaseFormSubmit}
|
||||
onEditStart={pur.handlePurchaseEditStart}
|
||||
onDelete={pur.handlePurchaseDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3406,3 +3406,127 @@
|
||||
border-radius:4px; font-size:10px; }
|
||||
.pipeline-card { cursor:pointer; }
|
||||
.pipeline-card:hover { background:rgba(255,255,255,.02); }
|
||||
|
||||
.psm-keyword-main { display: block; margin: 12px 0; font-size: 13px; }
|
||||
.psm-keyword-main input {
|
||||
display: block; width: 100%; margin-top: 4px; padding: 8px;
|
||||
background: rgba(255,255,255,.04);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 8px; color: var(--ms-text, #f0f0f5); font-size: 13px;
|
||||
}
|
||||
.psm-keyword-main small {
|
||||
display: block; color: var(--ms-muted, #a0a0b0); font-size: 11px; margin-top: 4px;
|
||||
}
|
||||
|
||||
/* === Batch Generation Section === */
|
||||
.ms-batch-section {
|
||||
margin: 16px 0;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 12px;
|
||||
}
|
||||
.ms-batch-section summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
user-select: none;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ms-batch-section[open] summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ms-batch-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.ms-batch-form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
}
|
||||
.ms-batch-form select,
|
||||
.ms-batch-form input[type="range"] {
|
||||
padding: 6px 8px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
border-radius: 6px;
|
||||
color: var(--ms-text, #f0f0f5);
|
||||
}
|
||||
.ms-batch-form input[type="range"] {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.ms-batch-checkbox {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.ms-batch-checkbox input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
.ms-batch-estimate {
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
margin: 4px 0;
|
||||
}
|
||||
.ms-batch-form button {
|
||||
padding: 8px 16px;
|
||||
background: rgba(56, 189, 248, 0.2);
|
||||
color: #bae6fd;
|
||||
border: 1px solid rgba(56, 189, 248, 0.4);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: bold;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.ms-batch-form button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ms-batch-progress {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--ms-line, #2a2a3a);
|
||||
}
|
||||
.ms-batch-header {
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ms-batch-status--generating { color: var(--ms-accent, #38bdf8); }
|
||||
.ms-batch-status--compiling { color: #fb923c; }
|
||||
.ms-batch-status--piped { color: #86efac; }
|
||||
.ms-batch-status--failed { color: #fca5a5; }
|
||||
.ms-batch-status--cancelled { color: var(--ms-muted, #a0a0b0); }
|
||||
.ms-batch-tracks {
|
||||
padding-left: 24px;
|
||||
font-size: 12px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
.ms-batch-tracks li {
|
||||
margin: 2px 0;
|
||||
}
|
||||
.ms-batch-tracks li.done {
|
||||
color: #86efac;
|
||||
}
|
||||
.ms-batch-tracks li.current {
|
||||
color: var(--ms-accent, #38bdf8);
|
||||
font-weight: bold;
|
||||
}
|
||||
.ms-batch-tracks li.pending {
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
.ms-batch-link {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--ms-muted, #a0a0b0);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
getTimestampedLyrics,
|
||||
generateStyleBoost,
|
||||
generateVideo,
|
||||
startBatchGen,
|
||||
getBatchJob,
|
||||
listGenres,
|
||||
} from '../../api';
|
||||
import PullToRefresh from '../../components/PullToRefresh';
|
||||
import FAB from '../../components/FAB';
|
||||
@@ -28,6 +31,7 @@ import StemModal from './components/StemModal';
|
||||
import SyncedLyricsPlayer from './components/SyncedLyricsPlayer';
|
||||
import RemixTab from './components/RemixTab';
|
||||
import YoutubeTab from './components/YoutubeTab';
|
||||
import BatchProgress from './components/BatchProgress';
|
||||
|
||||
/* ─────────────────────────────────────────────
|
||||
데이터 상수
|
||||
@@ -592,6 +596,17 @@ export default function MusicStudio() {
|
||||
const pollRef = useRef(null);
|
||||
const taskIdRef = useRef(null);
|
||||
|
||||
/* ── 배치 생성 상태 ── */
|
||||
const [batchOpen, setBatchOpen] = useState(false);
|
||||
const [batchGenre, setBatchGenre] = useState('lo-fi');
|
||||
const [batchCount, setBatchCount] = useState(10);
|
||||
const [batchDuration, setBatchDuration] = useState(180);
|
||||
const [batchAutoPipe, setBatchAutoPipe] = useState(true);
|
||||
const [currentBatch, setCurrentBatch] = useState(null);
|
||||
const [batchPolling, setBatchPolling] = useState(false);
|
||||
const [batchGenresList, setBatchGenresList] = useState(['lo-fi', 'phonk', 'ambient', 'pop']);
|
||||
const batchPollRef = useRef(null);
|
||||
|
||||
const activeGenre = GENRES.find((g) => g.id === genre);
|
||||
const accentColor = activeGenre?.color ?? '#f5a623';
|
||||
|
||||
@@ -651,6 +666,56 @@ export default function MusicStudio() {
|
||||
/* ── 언마운트 시 폴링 정리 ── */
|
||||
useEffect(() => () => clearInterval(pollRef.current), []);
|
||||
|
||||
/* ── 배치 생성 시작 ── */
|
||||
const startBatch = async () => {
|
||||
try {
|
||||
const res = await startBatchGen({
|
||||
genre: batchGenre,
|
||||
count: batchCount,
|
||||
target_duration_sec: batchDuration,
|
||||
auto_pipeline: batchAutoPipe,
|
||||
});
|
||||
setCurrentBatch(res);
|
||||
setBatchPolling(true);
|
||||
} catch (e) {
|
||||
alert(`배치 시작 실패: ${e.message || e}`);
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 배치: 지원 장르 목록 fetch (mount 시 1회) ── */
|
||||
useEffect(() => {
|
||||
listGenres()
|
||||
.then((r) => {
|
||||
if (Array.isArray(r?.genres) && r.genres.length) {
|
||||
setBatchGenresList(r.genres);
|
||||
if (!r.genres.includes(batchGenre)) setBatchGenre(r.genres[0]);
|
||||
}
|
||||
})
|
||||
.catch(() => { /* fallback hardcoded list 유지 */ });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ── 배치 폴링 ── */
|
||||
useEffect(() => {
|
||||
if (!batchPolling || !currentBatch?.id) return;
|
||||
const tick = async () => {
|
||||
try {
|
||||
const j = await getBatchJob(currentBatch.id);
|
||||
if (j) {
|
||||
setCurrentBatch(j);
|
||||
if (['piped', 'failed', 'cancelled'].includes(j.status)) {
|
||||
setBatchPolling(false);
|
||||
// library 갱신 (새 트랙들 표시되도록)
|
||||
if (typeof loadLibrary === 'function') loadLibrary();
|
||||
}
|
||||
}
|
||||
} catch { /* swallow */ }
|
||||
};
|
||||
batchPollRef.current = setInterval(tick, 5000);
|
||||
return () => clearInterval(batchPollRef.current);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [batchPolling, currentBatch?.id]);
|
||||
|
||||
/* ── helpers ── */
|
||||
const toggleMood = (id) =>
|
||||
setMoods((prev) =>
|
||||
@@ -1276,6 +1341,44 @@ export default function MusicStudio() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Batch Generation Section */}
|
||||
<details className="ms-batch-section" open={batchOpen} onToggle={(e) => setBatchOpen(e.currentTarget.open)}>
|
||||
<summary>🎲 배치 생성 (장르 → 1-10트랙 + 자동 영상)</summary>
|
||||
<div className="ms-batch-form">
|
||||
<label>장르
|
||||
<select value={batchGenre} onChange={e => setBatchGenre(e.target.value)}>
|
||||
{batchGenresList.map(g => (
|
||||
<option key={g} value={g}>
|
||||
{g.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join('-')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label>트랙 수: <strong>{batchCount}</strong>
|
||||
<input type="range" min={1} max={10} value={batchCount}
|
||||
onChange={e => setBatchCount(parseInt(e.target.value))} />
|
||||
</label>
|
||||
<label>트랙당 길이: <strong>{batchDuration}초</strong>
|
||||
<input type="range" min={60} max={300} step={10} value={batchDuration}
|
||||
onChange={e => setBatchDuration(parseInt(e.target.value))} />
|
||||
</label>
|
||||
<label className="ms-batch-checkbox">
|
||||
<input type="checkbox" checked={batchAutoPipe}
|
||||
onChange={e => setBatchAutoPipe(e.target.checked)} />
|
||||
모든 트랙 생성 후 자동 영상 파이프라인 시작
|
||||
</label>
|
||||
<p className="ms-batch-estimate">
|
||||
예상: 약 {Math.ceil(batchCount * 1.5)}–{batchCount * 2}분 ·
|
||||
{' '}비용 ~${(batchCount * 0.005 + (batchAutoPipe ? 0.05 : 0)).toFixed(2)}
|
||||
</p>
|
||||
<button className="button primary" onClick={startBatch}
|
||||
disabled={batchPolling}>
|
||||
🎵 배치 생성 시작
|
||||
</button>
|
||||
</div>
|
||||
{currentBatch && <BatchProgress batch={currentBatch} />}
|
||||
</details>
|
||||
|
||||
{/* Step 1: Genre */}
|
||||
<section className="ms-section">
|
||||
<div className="ms-section__head">
|
||||
|
||||
48
src/pages/music/components/BatchProgress.jsx
Normal file
48
src/pages/music/components/BatchProgress.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
const STATUS_LABELS = {
|
||||
queued: '대기 중',
|
||||
generating: '음악 생성 중',
|
||||
generated: '음악 완료, 컴파일 대기',
|
||||
compiling: '컴파일 중',
|
||||
piped: '영상 파이프라인 시작됨 — YouTube 탭 진행 탭에서 확인',
|
||||
failed: '실패',
|
||||
cancelled: '취소',
|
||||
};
|
||||
|
||||
export default function BatchProgress({ batch }) {
|
||||
if (!batch) return null;
|
||||
const trackList = Array.from({ length: batch.count }, (_, i) => i + 1);
|
||||
return (
|
||||
<div className="ms-batch-progress">
|
||||
<div className="ms-batch-header">
|
||||
배치 #{batch.id} — <strong>{batch.genre}</strong> ·{' '}
|
||||
{batch.completed}/{batch.count} 완료 ·{' '}
|
||||
상태: <strong className={`ms-batch-status ms-batch-status--${batch.status}`}>
|
||||
{STATUS_LABELS[batch.status] || batch.status}
|
||||
</strong>
|
||||
</div>
|
||||
{batch.error && <div className="ms-error">에러: {batch.error}</div>}
|
||||
<ol className="ms-batch-tracks">
|
||||
{trackList.map(n => {
|
||||
const completed = n <= batch.completed;
|
||||
const current = n === batch.current_track_index && batch.status === 'generating';
|
||||
const tr = (batch.tracks || [])[n - 1];
|
||||
return (
|
||||
<li key={n} className={completed ? 'done' : current ? 'current' : 'pending'}>
|
||||
{completed ? '✓' : current ? '⏳' : '○'}
|
||||
{' '}Track {n}: {tr?.title || (current ? '생성 중...' : '대기')}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
{batch.compile_job_id && (
|
||||
<div className="ms-batch-link">📀 컴파일 #{batch.compile_job_id}</div>
|
||||
)}
|
||||
{batch.pipeline_id && (
|
||||
<div className="ms-batch-link">
|
||||
🎬 영상 파이프라인 #{batch.pipeline_id} —{' '}
|
||||
<em>YouTube 탭 → 진행 탭에서 확인</em>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,16 @@ export default function PipelineStartModal({ library, initialTrackId, onClose, o
|
||||
</select>
|
||||
)}
|
||||
|
||||
<label className="psm-keyword-main">
|
||||
원하는 이미지 분위기 (선택)
|
||||
<input
|
||||
value={bgKeyword}
|
||||
onChange={e => setBgKeyword(e.target.value)}
|
||||
placeholder="예: 스케이트보드 파크 밝은 오후, 비 오는 카페 창가, 산 정상 일출 ..."
|
||||
/>
|
||||
<small>처음부터 cover 이미지 prompt에 반영됩니다. 비우면 장르 기본값 사용.</small>
|
||||
</label>
|
||||
|
||||
<details className="psm-advanced" open={advanced}>
|
||||
<summary onClick={(e) => { e.preventDefault(); setAdvanced(!advanced); }}>
|
||||
고급 옵션
|
||||
@@ -115,11 +125,6 @@ export default function PipelineStartModal({ library, initialTrackId, onClose, o
|
||||
<option value="video_loop">영상 루프 (Pexels)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
배경 키워드 (Pexels 검색용)
|
||||
<input value={bgKeyword} onChange={e => setBgKeyword(e.target.value)}
|
||||
placeholder="rainy window, lofi cafe ..." />
|
||||
</label>
|
||||
</details>
|
||||
|
||||
{error && <div className="ms-error">{error}</div>}
|
||||
|
||||
@@ -901,6 +901,22 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.pf-nxt-badge {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(139, 92, 246, 0.45);
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #c4b5fd;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
vertical-align: middle;
|
||||
cursor: help;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pf-edit-row {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
|
||||
@@ -245,6 +245,9 @@ const Stock = () => {
|
||||
<Link className="button ghost" to="/stock/trade">
|
||||
거래 데스크
|
||||
</Link>
|
||||
<Link className="button ghost" to="/stock/screener">
|
||||
스크리너
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="stock-card">
|
||||
|
||||
@@ -6,6 +6,26 @@ import {
|
||||
} from 'recharts';
|
||||
import { formatNumber, formatPercent, toNumeric, profitColorClass, numFitClass } from '../stockUtils';
|
||||
|
||||
const formatPriceTime = (iso) => {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const PriceSessionBadge = ({ session, asOf }) => {
|
||||
if (session !== 'NXT_AFTER' && session !== 'NXT_PRE') return null;
|
||||
const isPre = session === 'NXT_PRE';
|
||||
const label = isPre ? 'NXT 프리' : 'NXT';
|
||||
const desc = isPre ? 'NXT 프리마켓 거래가' : 'NXT 야간거래 (15:30~20:00)';
|
||||
const time = formatPriceTime(asOf);
|
||||
return (
|
||||
<span className="pf-nxt-badge" title={time ? `${desc} · ${time}` : desc}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
<>
|
||||
{pf.portfolioError ? (
|
||||
@@ -527,6 +547,10 @@ const PortfolioTab = ({ pf, asset, handleSell, handleSaveSnapshot }) => (
|
||||
{item.current_price != null
|
||||
? formatNumber(item.current_price)
|
||||
: '조회 실패'}
|
||||
<PriceSessionBadge
|
||||
session={item.price_session}
|
||||
asOf={item.price_as_of}
|
||||
/>
|
||||
</strong>
|
||||
</div>
|
||||
<div className="stock-holdings__metric">
|
||||
|
||||
82
src/pages/stock/screener/Screener.css
Normal file
82
src/pages/stock/screener/Screener.css
Normal file
@@ -0,0 +1,82 @@
|
||||
.screener-page {
|
||||
padding: 24px;
|
||||
color: var(--text, #e5e7eb);
|
||||
background: var(--bg, #0b0f17);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.screener-header {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.screener-header h1 {
|
||||
font-size: 28px;
|
||||
margin: 0 0 4px 0;
|
||||
}
|
||||
|
||||
.screener-header .meta {
|
||||
color: #9ca3af;
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.screener-header nav a {
|
||||
margin-left: 12px;
|
||||
color: #9ca3af;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.screener-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr 280px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
@media (max-width: 1023px) {
|
||||
.screener-page { padding: 16px; }
|
||||
.screener-header { flex-direction: column; align-items: flex-start; gap: 8px; }
|
||||
.screener-grid { grid-template-columns: 1fr; gap: 16px; }
|
||||
.screener-left { order: 1; }
|
||||
.screener-center { order: 2; }
|
||||
.screener-right { order: 3; }
|
||||
.screener-table { font-size: 12px; }
|
||||
.screener-table th, .screener-table td { padding: 6px 4px; }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.screener-page { padding: 12px; }
|
||||
.screener-card { padding: 12px; }
|
||||
}
|
||||
|
||||
.screener-loading { padding: 80px; text-align: center; color: #9ca3af; }
|
||||
|
||||
.screener-card {
|
||||
background: #0f1623;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.screener-card h3 { margin: 0 0 12px 0; font-size: 15px; }
|
||||
|
||||
.node-card {
|
||||
background: #0a0f1a;
|
||||
border: 1px solid #1f2937;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.node-card-header { font-weight: 500; margin-bottom: 6px; }
|
||||
.weight-row, .param-row { display: flex; align-items: center; gap: 6px; margin-top: 6px; }
|
||||
|
||||
.screener-table {
|
||||
width: 100%;
|
||||
font-size: 13px;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.screener-table th { text-align: left; padding: 8px; background: #0a0f1a; color: #9ca3af; font-weight: 500; border-bottom: 1px solid #1f2937; }
|
||||
.screener-table td { padding: 8px; border-bottom: 1px solid #1a2230; vertical-align: middle; }
|
||||
.screener-table tr:hover { background: #0a0f1a; }
|
||||
71
src/pages/stock/screener/Screener.jsx
Normal file
71
src/pages/stock/screener/Screener.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import './Screener.css';
|
||||
|
||||
import { useScreenerMeta } from './hooks/useScreenerMeta';
|
||||
import { useScreenerSettings } from './hooks/useScreenerSettings';
|
||||
import { useScreenerRun } from './hooks/useScreenerRun';
|
||||
import { useScreenerHistory } from './hooks/useScreenerHistory';
|
||||
|
||||
import GatePanel from './components/GatePanel';
|
||||
import NodePanel from './components/NodePanel';
|
||||
import GlobalControls from './components/GlobalControls';
|
||||
import ResultTable from './components/ResultTable';
|
||||
import TelegramPreview from './components/TelegramPreview';
|
||||
import RunHistoryList from './components/RunHistoryList';
|
||||
|
||||
export default function Screener() {
|
||||
const { meta, loading: metaLoading } = useScreenerMeta();
|
||||
const { settings, dirty, setLocal, save } = useScreenerSettings();
|
||||
const { result, running, runPreview, runSave } = useScreenerRun();
|
||||
const { runs, runs_loading, selectRun, selectedRun } = useScreenerHistory();
|
||||
|
||||
const activeResult = selectedRun || result;
|
||||
|
||||
if (metaLoading || !meta || !settings) {
|
||||
return <div className="screener-loading">로딩 중…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="screener-page">
|
||||
<header className="screener-header">
|
||||
<div>
|
||||
<h1>스크리너</h1>
|
||||
<p className="meta">
|
||||
최근 자동 잡: {runs?.find(r => r.mode === 'auto')?.asof ?? '-'}
|
||||
· 분석 기준일: {activeResult?.asof ?? settings.asof ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
<nav>
|
||||
<Link to="/stock">시장</Link>
|
||||
<Link to="/stock/trade">트레이드</Link>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<div className="screener-grid">
|
||||
<aside className="screener-left">
|
||||
<GatePanel meta={meta.gate_nodes[0]} value={settings.gate_params} onChange={(p) => setLocal({...settings, gate_params: p})} />
|
||||
<NodePanel meta={meta.score_nodes} weights={settings.weights} params={settings.node_params}
|
||||
onWeights={(w) => setLocal({...settings, weights: w})}
|
||||
onParams={(p) => setLocal({...settings, node_params: p})} />
|
||||
<GlobalControls settings={settings} setSettings={setLocal}
|
||||
onRun={() => runPreview(settings)}
|
||||
onSave={() => runSave(settings)}
|
||||
onPersist={save}
|
||||
dirty={dirty}
|
||||
running={running} />
|
||||
</aside>
|
||||
|
||||
<main className="screener-center">
|
||||
<ResultTable result={activeResult} />
|
||||
<TelegramPreview payload={activeResult?.telegram_payload} />
|
||||
</main>
|
||||
|
||||
<aside className="screener-right">
|
||||
<RunHistoryList runs={runs} loading={runs_loading} onSelect={selectRun}
|
||||
selectedId={selectedRun?.meta?.id} />
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/pages/stock/screener/components/GatePanel.jsx
Normal file
41
src/pages/stock/screener/components/GatePanel.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
export default function GatePanel({ meta, value, onChange }) {
|
||||
if (!meta) return null;
|
||||
const props = meta.param_schema?.properties || {};
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>{meta.label}</h3>
|
||||
<p style={{ fontSize: 11, color: '#9ca3af', marginTop: 0 }}>
|
||||
통과 조건 — 통과한 종목만 점수 노드에 전달
|
||||
</p>
|
||||
{Object.entries(props).map(([key, prop]) => (
|
||||
<GateField key={key} paramKey={key} prop={prop}
|
||||
value={value?.[key] ?? meta.default_params?.[key]}
|
||||
onChange={(v) => onChange({ ...value, [key]: v })} />
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function GateField({ paramKey, prop, value, onChange }) {
|
||||
if (prop.type === 'integer') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<label style={{ width: 160, fontSize: 12 }}>{paramKey}</label>
|
||||
<input type="number" value={value ?? ''}
|
||||
min={prop.minimum} onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
style={{ flex: 1 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (prop.type === 'boolean') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<label>
|
||||
<input type="checkbox" checked={!!value} onChange={(e) => onChange(e.target.checked)} />
|
||||
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
43
src/pages/stock/screener/components/GlobalControls.jsx
Normal file
43
src/pages/stock/screener/components/GlobalControls.jsx
Normal file
@@ -0,0 +1,43 @@
|
||||
export default function GlobalControls({ settings, setSettings, onRun, onSave, onPersist, dirty, running }) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>실행 옵션</h3>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>Top N</label>
|
||||
<input type="number" value={settings.top_n}
|
||||
onChange={(e) => setSettings({ ...settings, top_n: parseInt(e.target.value, 10) })}
|
||||
min={5} max={100} style={{ width: 80 }} />
|
||||
</div>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>ATR window</label>
|
||||
<input type="number" value={settings.atr_window}
|
||||
onChange={(e) => setSettings({ ...settings, atr_window: parseInt(e.target.value, 10) })}
|
||||
min={5} max={50} style={{ width: 80 }} />
|
||||
</div>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>손절 ×ATR</label>
|
||||
<input type="number" value={settings.atr_stop_mult} step={0.1}
|
||||
onChange={(e) => setSettings({ ...settings, atr_stop_mult: parseFloat(e.target.value) })}
|
||||
min={0.5} max={5} style={{ width: 80 }} />
|
||||
</div>
|
||||
<div className="param-row">
|
||||
<label style={{ width: 80, fontSize: 12 }}>R:R 비율</label>
|
||||
<input type="number" value={settings.rr_ratio} step={0.1}
|
||||
onChange={(e) => setSettings({ ...settings, rr_ratio: parseFloat(e.target.value) })}
|
||||
min={1} max={10} style={{ width: 80 }} />
|
||||
</div>
|
||||
<button onClick={onRun} disabled={running}
|
||||
style={{ marginTop: 16, width: '100%', padding: 10, background: '#fbbf24', color: '#0b0f17', border: 'none', borderRadius: 6, fontWeight: 600 }}>
|
||||
{running ? '실행 중…' : '지금 실행 (미리보기)'}
|
||||
</button>
|
||||
<button onClick={onSave} disabled={running}
|
||||
style={{ marginTop: 8, width: '100%', padding: 8 }}>
|
||||
스냅샷 저장
|
||||
</button>
|
||||
<button onClick={onPersist} disabled={!dirty}
|
||||
style={{ marginTop: 8, width: '100%', padding: 8, opacity: dirty ? 1 : 0.5 }}>
|
||||
설정 저장 (디폴트 갱신)
|
||||
</button>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
80
src/pages/stock/screener/components/NodeCard.jsx
Normal file
80
src/pages/stock/screener/components/NodeCard.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function NodeCard({ meta, weight, params, onWeightChange, onParamsChange }) {
|
||||
const enabled = (weight ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="node-card" style={{ opacity: enabled ? 1 : 0.6 }}>
|
||||
<div className="node-card-header">
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(e) => onWeightChange(e.target.checked ? (weight || 1) : 0)}
|
||||
/>
|
||||
<span>{meta.label}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="node-card-body">
|
||||
<div className="weight-row">
|
||||
<span style={{ width: 50, fontSize: 12, color: '#9ca3af' }}>가중치</span>
|
||||
<input
|
||||
type="range" min="0" max="3" step="0.1"
|
||||
value={weight ?? 0}
|
||||
disabled={!enabled}
|
||||
onChange={(e) => onWeightChange(parseFloat(e.target.value))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<span style={{ width: 32, textAlign: 'right', fontSize: 12 }}>{(weight ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
{Object.entries(meta.param_schema?.properties || {}).map(([key, prop]) => (
|
||||
<ParamRow
|
||||
key={key}
|
||||
paramKey={key}
|
||||
prop={prop}
|
||||
value={params?.[key] ?? meta.default_params?.[key]}
|
||||
disabled={!enabled}
|
||||
onChange={(v) => onParamsChange({ ...params, [key]: v })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ParamRow({ paramKey, prop, value, disabled, onChange }) {
|
||||
const type = prop.type;
|
||||
if (type === 'integer' || type === 'number') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<span style={{ width: 100, fontSize: 12 }}>{paramKey}</span>
|
||||
<input
|
||||
type="number"
|
||||
min={prop.minimum} max={prop.maximum}
|
||||
step={type === 'integer' ? 1 : 0.1}
|
||||
value={value ?? ''}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(type === 'integer' ? parseInt(e.target.value, 10) : parseFloat(e.target.value))}
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<div className="param-row">
|
||||
<label>
|
||||
<input type="checkbox" checked={!!value} disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)} />
|
||||
<span style={{ marginLeft: 6, fontSize: 12 }}>{paramKey}</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// object/array는 MVP에서 read-only JSON 표시 (RsRating의 weights 등)
|
||||
return (
|
||||
<div className="param-row" style={{ fontSize: 11, color: '#9ca3af' }}>
|
||||
{paramKey}: <code>{JSON.stringify(value)}</code>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/pages/stock/screener/components/NodePanel.jsx
Normal file
21
src/pages/stock/screener/components/NodePanel.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import NodeCard from './NodeCard';
|
||||
|
||||
export default function NodePanel({ meta, weights, params, onWeights, onParams }) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>점수 노드 ({meta.length})</h3>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{meta.map((m) => (
|
||||
<NodeCard
|
||||
key={m.name}
|
||||
meta={m}
|
||||
weight={weights[m.name]}
|
||||
params={params[m.name]}
|
||||
onWeightChange={(w) => onWeights({ ...weights, [m.name]: w })}
|
||||
onParamsChange={(p) => onParams({ ...params, [m.name]: p })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
54
src/pages/stock/screener/components/ResultTable.jsx
Normal file
54
src/pages/stock/screener/components/ResultTable.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import ScoreChips from './ScoreChips';
|
||||
|
||||
export default function ResultTable({ result }) {
|
||||
if (!result) {
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<p style={{ color: '#9ca3af' }}>아직 결과 없음. "지금 실행"을 눌러보세요.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0 }}>
|
||||
Top {result.top_n} · 통과 {result.survivors_count} · {result.asof}
|
||||
</h3>
|
||||
{result.warnings?.length > 0 && (
|
||||
<div style={{
|
||||
background: '#7c2d12', color: '#fde68a', padding: '4px 10px',
|
||||
borderRadius: 4, fontSize: 12,
|
||||
}}>
|
||||
⚠ {result.warnings.join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ overflowX: 'auto', marginTop: 12 }}>
|
||||
<table className="screener-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th><th>종목</th><th>총점</th><th>노드</th>
|
||||
<th>진입</th><th>손절</th><th>익절</th><th>R%</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(result.results || []).map((r) => (
|
||||
<tr key={r.ticker}>
|
||||
<td>{r.rank}</td>
|
||||
<td>{r.name}<br /><span style={{ fontSize: 11, color: '#9ca3af' }}>{r.ticker}</span></td>
|
||||
<td style={{ fontWeight: 600 }}>{r.total_score?.toFixed(1)}</td>
|
||||
<td><ScoreChips scores={r.scores} /></td>
|
||||
<td>{r.entry_price?.toLocaleString?.()}</td>
|
||||
<td>{r.stop_price?.toLocaleString?.()}</td>
|
||||
<td>{r.target_price?.toLocaleString?.()}</td>
|
||||
<td>{r.r_pct?.toFixed?.(1)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
17
src/pages/stock/screener/components/RunHistoryList.jsx
Normal file
17
src/pages/stock/screener/components/RunHistoryList.jsx
Normal file
@@ -0,0 +1,17 @@
|
||||
export default function RunHistoryList({ runs, loading, onSelect, selectedId }) {
|
||||
if (loading) return <section className="screener-card"><p>로딩…</p></section>;
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>최근 실행</h3>
|
||||
<ul style={{listStyle:'none', padding:0, margin:0, fontSize:13}}>
|
||||
{(runs || []).map((r) => (
|
||||
<li key={r.id} style={{padding:'6px 0', borderBottom:'1px solid #1f2937', cursor:'pointer',
|
||||
color: selectedId === r.id ? '#fbbf24' : '#e5e7eb'}}
|
||||
onClick={() => onSelect(r.id)}>
|
||||
{r.asof} · {r.mode}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
src/pages/stock/screener/components/ScoreChips.jsx
Normal file
32
src/pages/stock/screener/components/ScoreChips.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
const NODE_ICONS = {
|
||||
foreign_buy: { icon: '👤', label: '외국인' },
|
||||
volume_surge: { icon: '⚡', label: '거래량' },
|
||||
momentum: { icon: '🚀', label: '모멘텀' },
|
||||
high52w: { icon: '🆙', label: '52w고' },
|
||||
rs_rating: { icon: '💪', label: 'RS' },
|
||||
ma_alignment: { icon: '📈', label: '정배열' },
|
||||
vcp_lite: { icon: '🌀', label: 'VCP' },
|
||||
};
|
||||
|
||||
export default function ScoreChips({ scores }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{Object.entries(scores || {}).map(([name, s]) => {
|
||||
const meta = NODE_ICONS[name];
|
||||
if (!meta) return null;
|
||||
const active = s >= 70;
|
||||
return (
|
||||
<span key={name}
|
||||
title={`${meta.label}: ${s.toFixed?.(0) ?? s}`}
|
||||
style={{
|
||||
padding: '2px 6px', borderRadius: 4, fontSize: 11,
|
||||
background: active ? '#fbbf24' : '#1f2937',
|
||||
color: active ? '#0b0f17' : '#9ca3af',
|
||||
}}>
|
||||
{meta.icon}{Math.round(s)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/pages/stock/screener/components/TelegramPreview.jsx
Normal file
9
src/pages/stock/screener/components/TelegramPreview.jsx
Normal file
@@ -0,0 +1,9 @@
|
||||
export default function TelegramPreview({ payload }) {
|
||||
if (!payload) return null;
|
||||
return (
|
||||
<section className="screener-card">
|
||||
<h3>텔레그램 미리보기</h3>
|
||||
<pre style={{whiteSpace:'pre-wrap', fontFamily:'monospace', fontSize:12}}>{payload.text}</pre>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
src/pages/stock/screener/hooks/useScreenerHistory.js
Normal file
32
src/pages/stock/screener/hooks/useScreenerHistory.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { listScreenerRuns, getScreenerRun } from '../../../../api';
|
||||
|
||||
export function useScreenerHistory() {
|
||||
const [runs, setRuns] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedRun, setSelectedRun] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
listScreenerRuns(30).then((r) => { setRuns(r); setLoading(false); });
|
||||
}, []);
|
||||
|
||||
async function selectRun(id) {
|
||||
if (!id) { setSelectedRun(null); return; }
|
||||
const detail = await getScreenerRun(id);
|
||||
setSelectedRun({
|
||||
asof: detail.meta.asof,
|
||||
mode: detail.meta.mode,
|
||||
status: detail.meta.status,
|
||||
run_id: detail.meta.id,
|
||||
survivors_count: detail.meta.survivors_count,
|
||||
weights: detail.meta.weights,
|
||||
top_n: detail.meta.top_n,
|
||||
results: detail.results,
|
||||
telegram_payload: null,
|
||||
warnings: [],
|
||||
meta: detail.meta,
|
||||
});
|
||||
}
|
||||
|
||||
return { runs, runs_loading: loading, selectedRun, selectRun };
|
||||
}
|
||||
11
src/pages/stock/screener/hooks/useScreenerMeta.js
Normal file
11
src/pages/stock/screener/hooks/useScreenerMeta.js
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getScreenerNodes } from '../../../../api';
|
||||
|
||||
export function useScreenerMeta() {
|
||||
const [meta, setMeta] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
getScreenerNodes().then((m) => { setMeta(m); setLoading(false); });
|
||||
}, []);
|
||||
return { meta, loading };
|
||||
}
|
||||
31
src/pages/stock/screener/hooks/useScreenerRun.js
Normal file
31
src/pages/stock/screener/hooks/useScreenerRun.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react';
|
||||
import { runScreener } from '../../../../api';
|
||||
|
||||
export function useScreenerRun() {
|
||||
const [result, setResult] = useState(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
|
||||
async function call(mode, settings) {
|
||||
setRunning(true);
|
||||
try {
|
||||
const body = {
|
||||
mode,
|
||||
weights: settings.weights,
|
||||
node_params: settings.node_params,
|
||||
gate_params: settings.gate_params,
|
||||
top_n: settings.top_n,
|
||||
};
|
||||
const r = await runScreener(body);
|
||||
setResult(r);
|
||||
return r;
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result, running,
|
||||
runPreview: (s) => call('preview', s),
|
||||
runSave: (s) => call('manual_save', s),
|
||||
};
|
||||
}
|
||||
26
src/pages/stock/screener/hooks/useScreenerSettings.js
Normal file
26
src/pages/stock/screener/hooks/useScreenerSettings.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getScreenerSettings, saveScreenerSettings } from '../../../../api';
|
||||
|
||||
export function useScreenerSettings() {
|
||||
const [remote, setRemote] = useState(null);
|
||||
const [local, setLocal] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getScreenerSettings().then((s) => { setRemote(s); setLocal(s); });
|
||||
}, []);
|
||||
|
||||
const dirty = remote && local && JSON.stringify(remote) !== JSON.stringify(local);
|
||||
|
||||
async function save() {
|
||||
if (!local) return;
|
||||
const saved = await saveScreenerSettings({
|
||||
weights: local.weights, node_params: local.node_params, gate_params: local.gate_params,
|
||||
top_n: local.top_n, rr_ratio: local.rr_ratio,
|
||||
atr_window: local.atr_window, atr_stop_mult: local.atr_stop_mult,
|
||||
});
|
||||
setRemote(saved);
|
||||
setLocal(saved);
|
||||
}
|
||||
|
||||
return { settings: local, dirty, setLocal, save };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const Lotto = lazy(() => import('./pages/lotto/Lotto'));
|
||||
const Travel = lazy(() => import('./pages/travel/Travel'));
|
||||
const Stock = lazy(() => import('./pages/stock/Stock'));
|
||||
const StockTrade = lazy(() => import('./pages/stock/StockTrade'));
|
||||
const Screener = lazy(() => import('./pages/stock/screener/Screener'));
|
||||
const Subscription = lazy(() => import('./pages/subscription/Subscription'));
|
||||
const EffectLab = lazy(() => import('./pages/effect-lab/EffectLab'));
|
||||
const SwordStream = lazy(() => import('./pages/effect-lab/SwordStream'));
|
||||
@@ -160,6 +161,10 @@ export const appRoutes = [
|
||||
path: 'stock/trade',
|
||||
element: <StockTrade />,
|
||||
},
|
||||
{
|
||||
path: 'stock/screener',
|
||||
element: <Screener />,
|
||||
},
|
||||
{
|
||||
path: 'realestate',
|
||||
element: <Subscription />,
|
||||
|
||||
Reference in New Issue
Block a user