IdiotQuant API 문서
Cloudflare Workers 기반 퀀트 투자 백엔드 REST API
🔍 종목 발굴 스캔 주요
2,000개 종목을 5분마다 7개씩 롤링 스캔하여 stock_data_daily에 저장하는 메인 API입니다.
4가지 전략 태그(ncav · low_pbr · low_per · s_rim)를 strategies JSON 배열로 제공합니다.
ROE는 eps / bps(= 당기순이익 / 자본총계)로 계산됩니다.
날짜별 종목 발굴 목록. country=US로 미장 결과를 조회할 수 있습니다. 기본값은 국장입니다.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
country | string | KR | KR · US |
date | string | latest | YYYYMMDD 또는 latest |
strategy | string | all | all · ncav · low_pbr · low_per · s_rim |
limit | number | 50 | 최대 500 |
sort | string | ncav_ratio | ncav_ratio · pbr · per · last_price · market_cap |
order | string | desc | asc · desc |
# 최신 날짜 전체 종목 curl "https://idiotquant-backend.tofu89223.workers.dev/scan/daily?strategy=all&limit=300&sort=ncav_ratio&order=desc" # 미장 최신 종목 curl "https://idiotquant-backend.tofu89223.workers.dev/scan/daily?country=US&strategy=all&limit=300" # NCAV 전략만 필터 curl "https://idiotquant-backend.tofu89223.workers.dev/scan/daily?strategy=ncav&limit=50" # S-RIM 전략 (ROE > 8% && PBR < 1.0) curl "https://idiotquant-backend.tofu89223.workers.dev/scan/daily?strategy=s_rim&sort=pbr&order=asc" # 특정 날짜 저PBR curl "https://idiotquant-backend.tofu89223.workers.dev/scan/daily?date=20260601&strategy=low_pbr"
const res = await fetch(
'https://idiotquant-backend.tofu89223.workers.dev/scan/daily?strategy=all&limit=300&sort=ncav_ratio&order=desc'
);
const { success, data, meta } = await res.json();
// data: [{ ticker, name, scan_date, ncav_ratio, roe, per, pbr, eps, bps,
// current_assets, total_liabilities, market_cap, last_price, strategies }]
// strategies: string[] — e.g. ["ncav", "low_pbr"]
// roe: number (eps/bps) — e.g. 0.12 = 12%
console.log(`${meta.scanDate} 기준 ${meta.total}개 종목 (${meta.strategy})`);
{
"success": true,
"data": [
{
"ticker": "004830",
"name": "덕성",
"scan_date": "20260601",
"ncav_ratio": 2.341,
"current_assets": 85200,
"total_liabilities": 12400,
"market_cap": 31100,
"last_price": 4280,
"net_income": 3200000000,
"per": 9.8,
"pbr": 0.42,
"eps": 437,
"bps": 10190,
"roe": 0.04289,
"strategies": ["ncav", "low_pbr", "low_per"]
}
],
"meta": { "scanDate": "20260601", "strategy": "all", "total": 1, "sort": "ncav_ratio", "order": "DESC" }
}
💡 전략 기준: ncav ncav_ratio≥1.0 · low_pbr 0<pbr<0.5 · low_per 0<per<10 · s_rim roe>0.08 && 0<pbr<1.0
💡 ROE = eps / bps (주당순이익 / 주당순자산 = 당기순이익 / 자본총계)
스캔 날짜 목록과 날짜별 전략 종목 수. 최근 30일. 미장은 ?country=US를 붙입니다.
curl "https://idiotquant-backend.tofu89223.workers.dev/scan/daily/dates"
// 응답
{
"success": true,
"data": [
{
"scan_date": "20260601",
"total_cnt": 1847,
"ncav_cnt": 42,
"low_pbr_cnt": 312,
"low_per_cnt": 198,
"s_rim_cnt": 87
}
],
"meta": { "total": 14 }
}
특정 종목의 날짜별 시계열 (최근 14일, scan_date 내림차순). 미장 종목은 ?country=US를 붙입니다.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
:ticker | string | 필수 | 6자리 종목코드 (예: 005930) |
limit | number | 30 | 최대 200 |
curl "https://idiotquant-backend.tofu89223.workers.dev/scan/daily/ticker/004830?limit=14"
3단 아카이브 (일별 14일 → 주별 26주 → 월별 무기한). period 없이 호출하면 기간별 요약, 지정 시 종목 상세.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
period_type | string | monthly | monthly · weekly |
period | string | 없음 | 월별: YYYY-MM, 주별: YYYYMMDD(월요일). 없으면 요약 목록 |
strategy | string | all | ncav · low_pbr · low_per · s_rim · all |
limit | number | 50 | 최대 500 |
sort | string | ncav_ratio | ncav_ratio · pbr · per · last_price · period_label |
# 월별 요약 목록 curl "https://idiotquant-backend.tofu89223.workers.dev/scan/archive" # 2026년 6월 NCAV 종목 상세 curl "https://idiotquant-backend.tofu89223.workers.dev/scan/archive?period_type=monthly&period=2026-06&strategy=ncav&sort=ncav_ratio" # 주별 아카이브 요약 curl "https://idiotquant-backend.tofu89223.workers.dev/scan/archive?period_type=weekly"
// 월별 요약 (period 없음)
const { data } = await fetch('https://idiotquant-backend.tofu89223.workers.dev/scan/archive').then(r => r.json());
// data: [{ period_label, total_stocks, ncav_stocks, low_pbr_stocks, low_per_stocks, s_rim_stocks, avg_ncav_ratio }]
// 특정 월 상세 (period 지정)
const { data: detail } = await fetch(
'https://idiotquant-backend.tofu89223.workers.dev/scan/archive?period_type=monthly&period=2026-06&strategy=ncav'
).then(r => r.json());
// detail: [{ ticker, name, ncav_ratio, roe, per, pbr, eps, bps, days_scanned, strategies, ... }]
특정 종목의 아카이브 추이. 장기 가치 변화 분석·백테스트용.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
period_type | string | monthly | monthly · weekly |
limit | number | 24 | 최대 120 |
# 덕성(004830) 월별 24개월 추이
curl "https://idiotquant-backend.tofu89223.workers.dev/scan/archive/ticker/004830?period_type=monthly&limit=24"
// 응답
{
"data": [{ "period_label": "2026-06", "ncav_ratio": 2.34, "roe": 0.043, "per": 9.8, "pbr": 0.42, "days_scanned": 18 }, ...]
}
현재 스캔 진행 상태, 아카이브 진행 상태, DB 요약 통계를 반환합니다.
curl "https://idiotquant-backend.tofu89223.workers.dev/scan/status"
// 응답
{
"data": {
"scan": { "lastIndex": 350, "scanDate": "20260601", "totalStocks": 2000, "progressPct": 18, "done": false },
"archive": { "weekLabel": "20260526", "weekDone": false, "weekProgressPct": 42,
"monthLabel": "2026-06", "monthDone": false, "monthProgressPct": 12 },
"stockDataDaily": { "total_rows": 25858, "scan_days": 14, "unique_tickers": 1847, "latest_date": "20260601" },
"stockDataArchive": [
{ "period_type": "monthly", "periods": 2, "total_rows": 3694 },
{ "period_type": "weekly", "periods": 4, "total_rows": 7388 }
]
}
}
📊 종목 상세
종목 코드로 최신 스캔 결과 + 최근 이력을 조회합니다. stock_data_daily 기반, 인증 불필요.
curl "https://idiotquant-backend.tofu89223.workers.dev/stock/005930" curl "https://idiotquant-backend.tofu89223.workers.dev/stock/004830"
const { success, ticker, name, latest, history } = await fetch(
'https://idiotquant-backend.tofu89223.workers.dev/stock/005930'
).then(r => r.json());
if (success) {
console.log(`${name}(${ticker})`);
console.log(`NCAV 비율: ${latest.ncav_ratio.toFixed(2)}`);
console.log(`ROE: ${(latest.roe * 100).toFixed(1)}%`);
console.log(`전략: ${latest.strategies.join(', ')}`);
}
{
"success": true,
"ticker": "005930",
"name": "삼성전자",
"latest": {
"scan_date": "20260601",
"ncav_ratio": 1.423,
"last_price": 58400,
"market_cap": 3913440,
"per": 11.3,
"pbr": 0.98,
"eps": 5170,
"bps": 59600,
"roe": 0.08676,
"strategies": ["s_rim"]
},
"history": [
{ "scan_date": "20260601", "ncav_ratio": 1.423, "last_price": 58400 },
{ "scan_date": "20260525", "ncav_ratio": 1.401, "last_price": 57200 }
]
}
// 스캔 결과 없는 종목
{ "success": false, "error": "NCAV 스캔 결과에 '000000' 종목 없음. 아직 스캔되지 않았거나 NCAV 조건 미충족." }
💡 NCAV 비율 = (유동자산 − 총부채) ÷ 시가총액. 1.0 이상 = 이론적 청산가치 이하 거래 종목.
한국투자증권 UAPI 프록시. 국내·해외 시세/잔고/주문.
헤더: X-User-Id · X-User-Role · X-User-Plan 필요
💰 자동매매 계정 / Capital 토큰
자동매매 계정은 D1 trading_accounts 테이블에 등록합니다.
KR / US 시장별로 별도 행이며, 스케줄러(min%5===0)는 이 테이블을 조회해 활성 계정을 병렬 실행합니다.
Capital 토큰은 D1 capital_tokens 테이블에 저장되며, 매 5분 틱마다 월 예산 기반으로 active 종목에 균등 적립됩니다.
신규 계정 추가는 D1 insert로만 처리합니다. quant_rule_json이 NULL이면 전역 KV 룰 사용. user_id에는 반드시 kakaoId(providerAccountId)를 넣습니다 — capital_tokens·스케줄 매매가 kakaoId 키로 동작하므로 일치해야 합니다.
# KR 계정 등록
npx wrangler d1 execute idiotquant_main --remote --command="
INSERT INTO trading_accounts
(user_id, country, appkey, appsecret, account_number, monthly_budget_krw)
VALUES ('USER_ID', 'KR', 'APPKEY', 'APPSECRET', 'ACCOUNT_NO', 2000000)"
# US 계정 등록
npx wrangler d1 execute idiotquant_main --remote --command="
INSERT INTO trading_accounts
(user_id, country, appkey, appsecret, account_number, monthly_budget_krw)
VALUES ('USER_ID', 'US', 'APPKEY', 'APPSECRET', 'ACCOUNT_NO', 1000000)"
# per-user 투자 룰 지정 (NULL이면 전역 룰 사용)
npx wrangler d1 execute idiotquant_main --remote --command="
UPDATE trading_accounts
SET quant_rule_json='{"ncav_ratio":1.5,"active_count":10}'
WHERE user_id='USER_ID' AND country='KR'"
💡 monthly_budget_krw: KR / US 각각의 월 예산(원화). 스케줄러가 틱당 적립량 자동 계산.
국내 자본 토큰 상태 조회 (D1 capital_tokens). 종목별 누적 토큰, 매수/매도 액션, 스캔 인덱스 포함.
curl "https://idiotquant-backend.tofu89223.workers.dev/kr/capital" -H "X-User-Id: ADMIN_ID"
// 응답 (발췌)
{
"stock_list": [
{ "symbol": "005930", "name": "삼성전자", "token": 45200, "action": "active", "ncavRatio": "1.42" }
],
"charge_info": { "capital_charge_rate": 164 },
"action": "buy"
}
해외 자본 토큰 상태 조회. 환율(frst_bltn_exrt) 포함.
curl "https://idiotquant-backend.tofu89223.workers.dev/us/capital" -H "X-User-Id: ADMIN_ID"
active 종목 전체에 토큰 수동 추가. 특정 종목은 /ticker/{symbol} 사용. minus도 동일 패턴.
# 전체 active 종목에 3만원 추가 curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/kr/capital/token/plus/30000/all" -H "X-User-Id: ADMIN_ID" # 특정 종목에만 추가 curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/kr/capital/token/plus/30000/ticker/005930" -H "X-User-Id: ADMIN_ID" # 차감 curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/kr/capital/token/minus/30000/all" -H "X-User-Id: ADMIN_ID" curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/us/capital/token/plus/50000/all" -H "X-User-Id: ADMIN_ID"
🔐 인증
Kakao OAuth 로그인 리다이렉트. 성공 시 cf_token HttpOnly 쿠키 발급 (유효기간 1시간).
https://idiotquant-backend.tofu89223.workers.dev/kakao/login ← 브라우저에서 직접 접근
Kakao 세션 로그아웃. cf_token 쿠키 만료.
curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/kakao/logout" --cookie "cf_token=YOUR_TOKEN"
현재 로그인 유저 프로필 조회.
curl "https://idiotquant-backend.tofu89223.workers.dev/user/info" -H "X-User-Id: YOUR_USER_ID"
현재 로그인 유저의 관심 종목 목록.
📈 NCAV API 레거시
내부적으로 stock_data_daily를 사용하며 strategy=ncav 필터가 기본 적용됩니다.
신규 개발에는 /scan/* 엔드포인트 사용을 권장합니다.
날짜별 NCAV 조건 종목 목록. 내부적으로 /scan/daily?strategy=ncav와 동일.
| 파라미터 | 타입 | 기본값 | 설명 |
|---|---|---|---|
date | string | latest | YYYYMMDD 또는 latest |
min_ratio | number | 1.0 | 최소 NCAV 비율 |
limit | number | 50 | 최대 200 |
sort | string | ncav_ratio | ncav_ratio · pbr · per · last_price · market_cap |
order | string | desc | asc · desc |
curl "https://idiotquant-backend.tofu89223.workers.dev/ncav/daily?date=latest&min_ratio=1.5&limit=20"
월별 NCAV 압축 아카이브. period 없이 호출 시 월별 요약, 지정 시 종목 상세.
curl "https://idiotquant-backend.tofu89223.workers.dev/ncav/archive" curl "https://idiotquant-backend.tofu89223.workers.dev/ncav/archive?period=2026-06&min_ratio=1.0"
스캔 진행 상태 조회. → 현재는 /scan/status 사용 권장.
수동으로 1배치(7종목) 즉시 스캔. 테스트·디버깅용.
curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/ncav/scan/run" -H "X-User-Id: ADMIN_ID"
🔬 백테스트 / Strategy
국내 NCAV 백테스트 수동 실행. KIS API로 전체 종목 스캔 후 KV 저장. index로 중단점 재개 가능.
# 2025년 백테스트 시작 curl "https://idiotquant-backend.tofu89223.workers.dev/backtest/kr/year/2025" -H "X-User-Id: ADMIN_ID" # 100번 종목부터 재개 curl "https://idiotquant-backend.tofu89223.workers.dev/backtest/kr/year/2025/index/100" -H "X-User-Id: ADMIN_ID"
미국 NCAV 백테스트 수동 실행. Finnhub + KIS Overseas API 사용.
curl "https://idiotquant-backend.tofu89223.workers.dev/backtest/us/year/2025" -H "X-User-Id: ADMIN_ID"
국내 NCAV 전략 최신 결과 (KV 기반 구버전). 신규 개발에는 /scan/daily 사용 권장.
curl "https://idiotquant-backend.tofu89223.workers.dev/strategy/kr/ncav/date/latest" curl "https://idiotquant-backend.tofu89223.workers.dev/strategy/all/ncav/list"
🌐 외부 데이터
Financial Modeling Prep API 프록시. 미국 재무제표·가격 데이터.
curl "https://idiotquant-backend.tofu89223.workers.dev/fmp/AAPL"
Finnhub API 프록시. 미국 NCAV용 재무제표 (financials-reported). KV 캐시 적용.
curl "https://idiotquant-backend.tofu89223.workers.dev/finnhub/AAPL"
Cloudflare Workers AI (LLM) 추론. 모델: @cf/meta/llama-4-scout-17b-16e-instruct
curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/laboratory/llm" \
-H "Content-Type: application/json" \
-d '{"prompt": "삼성전자의 NCAV를 설명해줘"}'
⚙️ 기타
자동매매 알고리즘 거래 로그 조회 (KV IQ_PURCHASE_LOG 기반).
curl "https://idiotquant-backend.tofu89223.workers.dev/algorithm/trade" -H "X-User-Id: YOUR_ID"
유저 활동 타임스탬프 조회 및 갱신 (KV IQ_TIMESTAMP).
curl "https://idiotquant-backend.tofu89223.workers.dev/timestamp" -H "X-User-Id: YOUR_ID"
최근 24시간 종목 검색 순위 조회 (D1 D1_IQ_SEARCH_LOG).
# 검색 순위 상위 20개
curl "https://idiotquant-backend.tofu89223.workers.dev/api/search-log" -H "count: 20"
# 검색 기록 저장
curl -X POST "https://idiotquant-backend.tofu89223.workers.dev/api/search-log" \
-H "Content-Type: application/json" \
-d '{"ticker":"005930","name":"삼성전자","isUs":false}'
const data = await fetch('https://idiotquant-backend.tofu89223.workers.dev/api/search-log', {
headers: { count: '20' }
}).then(r => r.json());
await fetch('https://idiotquant-backend.tofu89223.workers.dev/api/search-log', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ticker: '005930', name: '삼성전자', isUs: false })
});
📋 공통 응답 형식
모든 API는 아래 형식으로 응답합니다.
// 성공
{ "success": true, "data": [...], "meta": { "total": 50, "scanDate": "20260601", ... } }
// 오류 (400 / 403 / 500)
{ "success": false, "error": "오류 메시지" }
• 모든 API는 CORS 헤더를 포함합니다.
• Admin 전용 API는 D1 users 테이블에 role='admin'인 유저만 접근 가능합니다.
• X-User-Role 헤더는 무시됩니다 — role은 DB에서만 결정됩니다.
📝 Changelog
- 2026-09-03: 관심 목록 조회를 인덱스 타는 두 단계로 (0033 — name 인덱스). Query Insights 로 잡았다: 이 조회 하나가 31회 실행에 968만 행, 하루 D1 읽기 1,000만 행의 97%였다. 호출당 31만 행이다. 원인은 세 갈래 매칭(ticker=ticker OR name=ticker OR name=stock_name)을 조인 조건에 넣고 거기에 상관 서브쿼리로 MAX(scan_date) 를 다시 구한 것 — OR 조인은 인덱스를 못 타고, 서브쿼리는 관심 종목 한 줄마다 stock_data_daily(3.5만 행) 를 또 훑는다. 이 목록은 로그인한 사람이 스크리너·분석 화면을 열 때마다 불리므로 사람이 늘수록 배로 늘었고, 한도를 넘긴 날에는 이것과 아무 상관 없는 화면까지 함께 멎었다(라우팅 앞의 users 조회가 같이 죽어서다). 이제 stock_likes 를 user_id 로 한 번(idx_sl_user_id), 그 종목들만 ticker/name IN 으로 한 번(idx_sdd_ticker + 새 idx_sdd_name) 읽고 "가장 최근 스캔일 고르기" 는 JS 가 한다 — 후보가 보관 기간 14일치뿐이라 메모리에서 고르는 편이 싸다. 결과 모양은 그대로다: 스캔에 없는 종목도 값만 비고 줄은 남고(옛 LEFT JOIN), ticker 자리에 종목명이 든 옛 데이터도 그대로 찾는다. test/stock-likes-query.test.js 가 그 셋과 "다시 전체 스캔으로 돌아가지 않았는가" 를 함께 건다.
- 2026-09-03: 요청 권한 조회가 던지지 않는다 (마이그레이션 없음). D1 일일 한도를 넘긴 상태에서 로그인한 사람은 모든 화면이 멎었다 — 적정주가 검색도, 관리자 사용량 카드도. 원인은 라우팅 앞에 있는 users 조회 한 줄이다. worker.js 는 요청마다(격리 캐시가 비었을 때) role·plan 을 채우려고 users 를 한 번 읽는데, 그게 던지면 바깥 catch 가 잡아 500 을 내보내고 라우트는 시작조차 못 한다. 그래서 한도와 아무 상관 없는 화면까지 함께 죽었다. 이 조회를 loadUserForRequestD1 으로 빼고 "못 읽으면 null, 절대 던지지 않는다" 를 계약으로 못 박았다. 실패하면 기본값(일반 사용자)으로 진행한다 — 권한이 줄어드는 방향이라 조회 실패가 권한을 열어 주는 일은 없다. D1 을 쓰는 라우트는 어차피 그 뒤에서 실패하지만, 안 쓰는 라우트는 계속 굴러간다. worker.js 는 .sql 을 텍스트로 import 해 node 로 못 부르므로(migrate.test.js 와 같은 제약) 그 앞의 순수 로직에 테스트를 건다(test/worker-user-lookup.test.js).
- 2026-09-03: /dashboard/scan 의 누적 통계를 ?full=1 뒤로 보내고 자동 새로고침을 뗐다 (마이그레이션 없음). 이틀 연속으로 D1 무료 한도(하루 500만 행 읽기)를 넘겨 로그인이 통째로 막혔다 — 어댑터의 첫 조회(getUserByAccount)부터 D1_ERROR 가 나서 카카오·구글이 같은 "Server error" 화면으로 떨어졌다. 원인이 이 화면이었다. 누적 요약·날짜별 현황·아카이브 요약 셋이 전부 WHERE 없는 집계라 stock_data_daily(14일치)와 stock_data_archive 를 통째로 훑는데, 여기에 meta refresh 60초가 걸려 있었다. 탭 하나만 열어 두면 시간당 60번이라 하루치 한도를 한 시간이 안 되어 다 쓴다. 자동 새로고침을 떼는 것만으로는 부족하다 — 한 번에 수만~수십만 행이라 사람이 스무 번만 눌러도 같은 곳에 닿는다. 그래서 기본 화면에서 아예 빼고 눌러야 나오게 했다. 평소에 보는 것(스캔 진행률·다음 배치·오늘 걸린 종목)은 상태 두 줄과 하루치만 읽으므로 그대로다. KV 캐시를 두지 않은 이유는 무료 쓰기가 하루 1,000회라 캐시 계층으로는 D1 보다 오히려 빡빡해서고, 애초에 안 읽으면 캐시도 필요 없다. test/scan-dashboard-reads.test.js 가 기본 화면에서 전체 집계가 돌면 깨진다.
- 2026-09-02: GET /admin/users 에 providers 추가 (마이그레이션 없음). 어드민 목록이 "카카오 가입자 목록" 이던 시절에는 물어볼 것이 없었지만, 구글 로그인이 열리면서 이 사람이 어느 문으로 들어왔는지가 갈렸다. 제공자는 users 가 아니라 accounts.provider 에 있고, 같은 이메일이면 계정이 하나로 합쳐지므로(allowDangerousEmailAccountLinking) 한 사람이 둘을 다 가질 수 있다 — 그래서 하나를 고르지 않고 있는 대로 이어 붙인다("kakao,google"). users 에 컬럼을 늘리지 않고 서브쿼리 하나로 읽는 이유는, 연결은 accounts 가 진실이고 users 에 사본을 두면 계정을 하나 더 이을 때 둘이 어긋나서다. group_concat(DISTINCT x) 는 구분자를 못 받아(SQLite 문법) 기본 쉼표를 그대로 쓴다. lastLoginAt 컬럼이 없는 환경으로 떨어지는 fallback 쿼리에도 같은 서브쿼리를 넣어, 그 환경에서도 화면이 빈 칸이 되지 않는다.
- 2026-08-28: 부서 — 금고를 써서 회사를 키운다 (마이그레이션 없음). 금고(firms.cash)에 돈이 들어올 길은 늘었는데(운용보수·성과보수·목표 보상 50만) 나갈 길이 리서치 도구 넷뿐이라, 다 사고 나면 쌓이기만 했다. 부서 셋을 연다 — 도구가 "차트를 더 잘 보게" 해 준다면 부서는 **판의 규칙 자체를 바꾼다.** 예약 데스크(300만): 걸어 둘 예약이 3건 → 5건. 리스크 관리팀(800만): 문 닫는 선이 최고점의 40% → 30%, 공매도 강제 청산이 담보의 80% → 90%. IR팀(2,000만): 벤치마크를 이겼을 때 들어오는 초과 유입에 ×1.2 (못한 반기의 유출은 그대로 — 못해도 덜 빠지면 부서가 곧 안전망이 되어 위험에 매긴 값이 도로 풀린다). 저장은 도구와 같은 firms.tools 배열을 쓴다 — 컬럼을 늘리지 않으려는 선택이고, 산 것이 도구인지 부서인지는 id 로 갈린다(buyToolD1 이 toolById ?? departmentById). 수수료 인하는 뺐다: 체결 규칙 전체에 인자가 번지는데, 위 셋이 이미 "덜 무섭게 / 더 크게 / 손이 덜 가게"라는 서로 다른 축을 준다. 규칙은 perksOf(tools) 한 곳에서 나오고, 정산(_finish·submit 양쪽)이 그 값을 settleQuarter 에 넘긴다 — 화면이 계산해 보낸 값을 믿지 않는다. 강제 청산 선(shortCallPct)만은 워커의 perksOf 에 없다: 판은 브라우저에서 돌고(0827) 워커는 체결을 판정하지 않아서, 안 쓰는 값을 두면 프론트와 어긋나도 아무도 모른다.
- 2026-08-28: 공매도 — replay_holdings 에 short_qty·short_basis (0032). 0031 에서 "한 주도 안 산 반기는 고객이 떠난다"를 넣었는데, 살 수만 있는 규칙에서는 크게 빠지는 반기에 대응할 수단이 없다: 사면 잃고 안 사면 벌을 받는다. 대응할 수 없는 자리에 벌만 주는 꼴이라 공매도를 연다(STONKS-9800 이 같은 문제에 내놓은 답이기도 하다). 돈이 도는 길: 판 대금을 손에 쥐지 않고 **그대로 담보로 묶는다** — 개시에 현금은 안 움직이고 판 값만큼이 묶여 살 돈이 줄고, 갚을 때 담보가 풀리며 값 차이가 손익으로 현금에 들어온다. 담보를 현금에서 빼면서 평가손익까지 더하면 같은 돈을 두 번 세게 되는데, 이 방식이면 내 돈 = 현금 + 롱 평가금액 + 공매도 평가손익 으로 딱 떨어진다(담보는 실수령으로 잡아 개시 직후 평가손익이 정확히 -수수료가 된다). 담보 100%, 레버리지는 열지 않았다 — 이자·한도·담보비율이 붙으면 화면이 복잡해지고, 하락장의 구멍을 메우는 데는 공매도만으로 충분하다. 평가손실이 담보의 80%를 넘으면 그날 종가로 강제 청산한다(값이 오르는 데는 끝이 없어서, 이 선이 없으면 담보보다 큰 빚을 지고 현금이 음수가 되는 판이 나온다). 빌린 주식은 이월되지 않아 반기 마감 때 무조건 갚는다 — submit 은 브라우저가 뭘 보내든 short_qty·short_basis 를 0 으로 못박는다. 워커에는 공매도 계산의 사본을 두지 않았다: 판은 브라우저에서 돌고(0827) 여기는 받아 적기만 하므로 규칙이 한 벌로 끝난다. replay_orders.side 에 short·cover 가 늘어 _cleanOrders 가 네 값만 받고 모르는 값은 sell 로 떨어뜨린다. 습관 계산의 투입 강도·최대 비중은 롱만 센다(공매도는 돈이 도는 길이 달라 같은 걸음으로 못 따라간다).
- 2026-08-28: 게임성 — 반기마다 다른 고객·목표, 그리고 문을 닫는 선 (0031). 여덟 반기가 전부 같은 질문("벤치마크를 이겨라")이었고, 아무리 잃어도 판이 계속 굴러갔다. 그러면 손절도 비중 조절도 할 이유가 없다 — 초과에 붙는 유입(×3)이 손실에 붙는 유출(×1.5)보다 커서 늘 최대한 몰아넣는 쪽이 기대값에서 앞선다. 넷을 넣었다. ① 고객: (campaign_id, half_index) 에서 파생한다(lib/season.js — 저장하지 않는다. 컬럼이 안 늘고 지난 기록을 열 때도 같은 값이 다시 나온다). 보수적 연기금은 손실 배수 ×2·초과 ×0.6, 헤지펀드 재간접은 초과 ×1.6·손실 ×0.5·성과보수 ×1.5, 개인 큰손은 양쪽 ×1.3, 대학 기금은 양쪽 ×0.5 — 같은 -5% 가 누구 앞이냐에 따라 다르게 평가된다. ② 목표: 같은 자리에서 뽑되 8비트 밀어 고객과 짝이 고정되지 않게 한다. 벤치마크 +3%p / 3자리 이상 담고 이기기 / 회전율 1.5배 이하로 이기기 / 한때 주식 70% 싣고 이기기 / 잃지 않고 이기기 — 전부 habits 가 이미 계산하는 값으로 판정해 새 계산이 없다. 달성하면 회사 자금 50만(도구 최저가가 30만이라 두어 번 해내면 하나 산다). 판정은 브라우저가 하고 보내지만 **보상 액수는 서버가 정한다** — 액수까지 클라이언트가 정하면 얼마든 적어 보낼 수 있는 창구가 된다. 캠페인 없이 굴린 판은 목표가 없어(mission_ok = NULL) 달성했다고 우겨도 보상이 없다. ③ 파산: firms.peak_aum 을 두고 최고점의 40% 아래로 떨어지면 회사가 문을 닫는다. 절대 금액이 아니라 최고점 대비인 이유는 1억을 굴리든 100억을 굴리든 "맡은 돈의 60% 가 사라지면 못 버틴다"가 규모와 무관하게 성립해서다. 문을 닫으면 회사를 새로 차리되(aum·peak_aum = 1억, quarters = 0, 이월 삭제) 이름·도구·모아 둔 자금은 남긴다 — 여태 쌓은 것이 통째로 사라지면 다시 시작할 마음이 안 든다. 굴릴 회사가 없으니 기간도 거기서 끝난다(endCampaignD1). 겸사겸사 오래된 함정을 막는다: firms.aum 에 CHECK (aum >= 10000000) 이 걸려 있어 1,000만 아래로 떨어지는 정산은 UPDATE 가 통째로 실패했는데(정산이 조용히 사라졌다), 파산 선(첫 회사 기준 4,000만)이 그보다 위라 이제 거기까지 가지 않는다. peak_aum 이 0 인 옛 회사는 판정하지 않는다 — 규칙이 없던 시절의 회사를 뒤늦게 폐업시키지 않는다. ④ 관망: 한 주도 안 산 반기는 성적과 무관하게 고객이 10% 빠지고 성과보수가 0 이다. 하락장에서 아무것도 안 사면 수익률 0 으로 벤치마크를 이기는데(그 자체는 맞는 계산이다) 그걸 보상하면 "아무것도 하지 않기"가 최적 전략이 되어 게임이 멈춘다. 한 주라도 사면 보통 규칙으로 돌아간다. 정산은 _finish(옛 경로)와 submit 양쪽에 같은 규칙이 걸린다 — 한쪽만 두면 그게 빠져나가는 구멍이 된다.
- 2026-08-27: 모의투자 판이 브라우저로 옮겨갔다 — /user/replay 에 submit·checkpoint 추가, start 와 GET 은 캔들을 통째로 준다. 지금까지는 매수 한 번, 하루 넘기기 한 번마다 브라우저 → Pages 프록시 → 워커 → D1 을 거쳤고 워커 안에서만 D1 왕복이 다섯 번이었다(_loadRound → _loadHoldings → batch → _loadRound → _loadOrders+_loadHoldings). 반기 하나면 70~100번, 400 왕복이 넘는다. 특히 자동 재생은 260ms 마다 그 왕복을 시켜서 뚝뚝 끊겼고, 매수는 응답이 올 때까지 버튼이 전부 회색으로 죽어 있어 더 길게 느껴졌다. 이제 start 가 반기 캔들을 전부 내려주고(_publicRound/_publicHolding 의 full 옵션) 사고팔기·하루 넘기기·예약 체결은 브라우저가 한다(cf-idiotquant/lib/paper/half.ts). 왕복은 반기당 둘 — 시작과 마감이다. 맞바꾼 것: 앞날이 브라우저 안에 있어 개발자 도구로 볼 수 있다. 혼자 하는 게임이고 비로그인 판(localRound.ts)은 원래 그랬다는 것을 알고 고른 절충이다. full 을 켜도 여는 것은 캔들뿐이고 종목명·코드·기간은 그대로 가린다(정답은 판이 끝나야 열린다). submit 은 브라우저가 계산한 결과를 그대로 저장한다 — 다시 검산하지 않는 것도 정해진 선택이다(순위표를 붙일 때가 되면 주문 목록을 서버가 다시 돌려 확정하면 된다. 캔들은 이미 D1 에 있다). 다만 D1 에 못 쓸 모양은 걸러 낸다: 음수 현금은 CHECK 에 걸려 판이 통째로 안 써지므로 0 으로 깎고, 판 밖의 day_index·없는 slot 은 범위 안으로 당기고, 체결은 500건까지 받는다. 이월분(carry_json)만은 서버가 만든다 — 종목명은 블라인드라 브라우저에 없는데 다음 반기가 같은 종목을 이어 가려면 그 이름이 필요하다. checkpoint 는 기기를 바꿔도 굴리던 판이 살아 있게 하는 것이고 화면은 응답을 기다리지 않는다(늦게 도착한 것이 진행을 되돌리지 않게 커서는 뒤로 못 간다. 체결 기록은 지우고 다시 넣어 여러 번 와도 안 쌓인다). 마감 정산은 _applySettlement 로 갈라 _finish 와 submit 이 같은 것을 쓴다. 옛 액션(trade·advance·reserve·giveup)은 그대로 두었다 — 프론트가 안 부를 뿐이고, full 을 안 켜면 응답도 예전처럼 커서까지만 나간다.
- 2026-08-27: 예약이 어느 자리(종목)에 걸린 것인지 남긴다. 체결 판정은 처음부터 자리마다 따로 돌고 있었다 — _fillReservations 가 holdings 를 훑으며 (res.slot ?? 0) === h.slot 인 예약만 그 종목 캔들로 본다. 그런데 걸 때 부르는 validateReservation 이 slot 을 빼고 {kind, price, qty} 만 돌려주고 있어서, 넷을 굴리는 판에서 어디에 걸든 전부 0번 자리 예약이 됐다. 2번 종목을 보며 손절을 걸어도 판정은 0번 값으로 나고, 화면의 칩에도 "손절 56,000원 30주" 만 떠서 어느 종목인지 알 수가 없었다. 이제 slot 을 실어 보내고 그대로 저장한다. 자리 수는 실제로 깔린 holdings 로 세어 그 밖의 자리는 거절한다 — 없는 자리에 걸어 두면 영영 체결되지 않는데, 걸어 둔 사람은 기다리고만 있게 된다. 자리를 안 보내면 0번으로 본다(종목이 하나뿐이던 시절 예약과 같은 취급이라 옛 기록도 그대로 돈다). slot 은 가격·수량과 같이 내림한다. 프론트는 예약을 걸 때 지금 보고 있는 자리를 함께 보내고 칩에 업종을 적는다.
- 2026-08-25: 신원을 헤더가 아니라 서명으로 정한다 (lib/identity.js). 지금까지 워커는 X-User-Id 헤더 한 줄을 그대로 신원으로 썼다 — 워커는 *.workers.dev 로 열려 있으니 남의 user id 를 아는 사람이면 그 헤더만 붙여 가계부·관심종목·모의투자를 읽고 쓸 수 있었다. 프론트 프록시가 jose 로 서명해 보내던 Authorization 토큰은 아무도 열어보지 않았다(자물쇠는 달렸는데 잠그는 코드가 없었다). id 가 UUID 라 무작위로는 못 맞히지만 가계부 공유가 그 id 를 ?owner= 로 돌린다 — 한 번 같이 쓴 사람은 상대 id 를 영구히 갖고, 초대를 끊어도 회수할 방법이 없었다. 이제 INTERNAL_JWT_SECRET 이 있으면 서명된 토큰만 신원으로 인정하고 헤더는 무시한다. 검증은 crypto.subtle.verify(상수 시간 — 직접 만든 서명과 === 로 견주면 응답 시간으로 한 바이트씩 맞춰볼 여지가 남는다)이고 exp 를 필수로 요구한다(없으면 영원히 사는 토큰이라 한 번 새면 회수할 길이 없다. 1분짜리라 시계 오차 30초는 봐준다). 검증된 id 로 X-User-Id 를 덮어써서 아래로 넘긴다 — market/uapi.js 처럼 헤더를 직접 읽는 코드가 남아 있어 그러지 않으면 위조가 그 자리로 샌다. 카카오 authToken 의 JWT_SECRET 과 일부러 다른 키다: 그 키를 돌리면 발급해둔 쿠키가 전부 무효가 되어 모든 사용자가 로그아웃된다. 시크릿이 없으면 예전처럼 헤더를 믿는다 — 배포와 시크릿 설정 사이에 서비스가 멎지 않게 하려는 것이고, 그 스위치를 쥔 것은 운영자뿐이다(공격자가 남의 워커에서 env 를 지울 수는 없다). 즉 양쪽에 INTERNAL_JWT_SECRET 을 넣기 전까지 이 수정은 켜지지 않는다. 남은 구멍: market/uapi.js 의 ?kakao-id= 쿼리는 여전히 헤더보다 우선한다(별도 과제).
- 2026-08-24: 가계부에 저축·투자(kind=saving) 추가. 마이그레이션은 없다 — ledger_entries.kind 는 CHECK 없는 TEXT 라 값 하나가 늘어날 뿐이고, /user/ledger 와 /user/ledger/categories 의 kind 검증에 saving 을 더하는 것이 전부다. 지출로 묶어 두면 적금·주식 매수가 소비 합계를 부풀려 "이번 달 지출 300만원"이 읽히지 않고, 잔액도 쓴 돈과 옮긴 돈을 구별하지 못한다(저축은 소비가 아니라 이체다 — 현금이 예금·주식으로 자리를 옮겼을 뿐 자산은 그대로다). 그래서 잔액 = 수입 − 소비 − 저축 이 되고 저축률 = 저축 / 수입 이 비로소 뜻을 갖는다. 기존 지출 프리셋의 invest("투자")는 saving 쪽으로 옮겼다 — 다만 이미 그 항목으로 적어둔 행은 건드리지 않는다(내 돈을 어디에 넣었는지는 적은 사람의 기록이지 마이그레이션이 정할 일이 아니다). 옮기려면 UPDATE ledger_entries SET kind='saving' WHERE kind='expense' AND category='invest' 를 대시보드에서 직접 실행한다.
- 2026-08-21: 자산 수명 계산기 저장 — /user/calculator (GET·POST·DELETE, 0030 calculator_runs). 입력값 열다섯 개를 컬럼으로 펼치지 않고 JSON 한 덩어리로 둔다: 워커는 읽지도 계산하지도 않고 그대로 돌려줄 뿐이고, 계산기 입력은 화면 사정으로 늘고 줄어서 컬럼으로 두면 항목마다 마이그레이션이 붙는다. 대신 결과 셋(final_value·final_rate·total_investment)은 컬럼으로 빼둔다 — 목록에서 "얼마 남았는지"를 보여주려면 매 줄의 JSON 을 열지 않고 읽을 수 있어야 한다. 한 사람당 30개 상한이고, 넘치면 저장을 거절하는 대신 오래된 것부터 스스로 지운다(거절하면 상한이 사용자의 일이 된다). mode 는 simple|standard|expert — 화면의 복잡도 단계를 그대로 적어둔다. 불러왔을 때 어느 단계에서 만든 계산인지 알아야 같은 숫자가 나온다. inputs 는 배열을 막고 항목 수 40개로 제한한다(배열도 typeof object 라 그냥 두면 아무 데이터나 담는 창구가 된다).
- 2026-08-21: 가계부 내역 끌어놓기 — POST /user/ledger/reorder?date= (0029 position). 받는 건 "이 날의 순서는 이것이다" 한 문장(ids)뿐이다: 같은 날 안에서 자리를 바꾼 것도, 다른 날에서 끌어온 것도 도착한 날의 목록으로는 똑같이 표현되므로 UPDATE 한 문장이 둘 다 한다(position 을 0..n-1 로 매기고 entry_date 를 도착한 날로). 떠나온 날은 손대지 않는다 — 한 줄이 빠져 번호에 구멍이 나도 순서는 그대로다. 날짜가 실제로 바뀐 줄만 수정자로 남긴다(CASE WHEN entry_date = ? — SET 오른쪽은 바뀌기 전 값으로 계산된다): 자리만 바꾼 것까지 "수정"으로 적으면 함께 쓰는 사람에게 없는 변경을 알린다. 0029 의 백필은 position = -id 다 — position 오름차순에서 그게 예전 기준(id 내림차순)과 정확히 같은 순서라 화면이 한 줄도 안 바뀐다. 새 내역은 그 날 MIN(position)-1 을 받아 맨 위로(이것도 예전 그대로). ids 는 200개 상한·중복 금지이고, 남의 줄이 섞여도 UPDATE 의 user_id 조건이 걸러낸다. 0029 는 ADD COLUMN 이라 재실행하면 duplicate column 으로 실패한다 — 한 번만 실행한다.
- 2026-08-20: 가계부 내역에 기록자·수정자(0028 created_by·updated_by·updated_at). 함께 쓰기 시작하면서 "이 5만원 누가 넣었지" 가 물어볼 만한 질문이 됐다. ledger_entries.user_id 는 가계부 주인이지 적은 사람이 아니다 — 초대받은 사람이 적어도 user_id 는 주인 것이라, 적은 사람을 따로 남긴다(그래서 insert/update 가 owner 와 actor 를 둘 다 받는다). 이름은 워커가 users 를 LEFT JOIN 해서 붙인다: 프론트가 id 로 찾게 두면 남의 가계부에서는 찾을 표가 없다. 수정은 updated_by/updated_at 만 갈아끼우고 created_by 는 두고 온다 — 처음 적은 사람이 바뀌면 기록이 아니다. 옛 행은 전부 NULL 이고 지어내지 않는다(화면도 그 줄을 아예 안 띄운다). 0028 은 ADD COLUMN 이라 재실행하면 duplicate column 으로 실패한다 — 한 번만 실행한다.
- 2026-08-20: 가계부 항목 이름 바꾸기 — PUT /user/ledger/categories?id= 추가(0027). 내역이 라벨을 품고 있으면(custom:여행) 이름 한 번 바꿀 때마다 그 사람 내역을 전부 훑어야 한다. 그래서 내역은 항목 행을 가리키고(cat:12) 라벨은 ledger_categories 한 곳에만 둔다 — 이름 변경이 한 행 UPDATE 로 끝난다. 스키마는 그대로다(category 는 여전히 자유 TEXT). 대신 삭제가 한 번 일한다: 지우기 직전에 그 내역들을 custom:<그때 라벨> 로 굳혀둔 뒤 행을 지운다(batch) — 그러지 않으면 과거 내역이 이름을 잃는다. 훨씬 잦은 쪽(이름 바꾸기)을 공짜로 만들고 드문 쪽(삭제)에서 한 번 치르는 맞바꿈이다. 같은 구분에 같은 이름으로 바꾸려 하면 UNIQUE 제약이 터지기 전에 400 으로 막는다(제약 오류는 D1 메시지만 보인다). custom:<라벨> 은 계속 읽는다 — 이미 지운 항목으로 적어둔 내역이 그 모양이다.
- 2026-08-20: 던져진 오류를 JSON 으로 감싼다 — worker.fetch 전체를 try/catch 로 두르고 500 + {success:false, error}. 지금까지는 라우트가 던지면 Workers 가 HTML 예외 페이지(1101)를 돌려줬고, JSON 을 기대한 프론트는 파싱 단계에서 깨져 "The string did not match the expected pattern." 같은 브라우저 메시지만 남겼다 — 정작 원인(예: no such table: ledger_categories)은 어디에도 보이지 않았다. err.message 를 그대로 실어 화면에서 읽히게 한다.
- 2026-08-19: 가계부 공유 — 초대해서 함께 편집(0026 ledger_members·ledger_invites). 가계부에 별도 id 를 두지 않았다: 소유자의 user_id 가 곧 가계부 식별자다(ledger_entries.user_id 가 이미 그 뜻이라 기존 데이터를 옮길 일이 없다). /user/ledger 와 /user/ledger/categories 가 ?owner= 를 받고, 권한 판정은 resolveLedgerOwner 한 곳에만 있다 — 라우트마다 검사하면 하나를 빠뜨린다. 멤버가 아니면 403 이 아니라 404 다(403 이면 "그 사람이 가계부를 쓴다"가 새어나간다). 초대 링크는 1회용·7일 만료이고, 수락은 UPDATE ... WHERE accepted_by IS NULL 로 잡는다 — 그 조건이 곧 잠금이라 같은 링크를 동시에 눌러도 한 명만 들어온다. 발급은 소유자만(멤버가 또 부르면 소유자 모르게 사람이 는다). 이미 멤버가 링크를 다시 열면 링크를 태우지 않고 통과시킨다. /user/ledger/access 는 내 것을 항상 첫 번째로 준다.
- 2026-08-19: 가계부 사용자 항목 — /user/ledger/categories (GET·POST·DELETE, 0025 ledger_categories). ledger_entries.category 는 이미 자유 TEXT 라 이 표는 "칩으로 무엇을 보여줄지"만 정한다 — 항목을 지워도 그 항목으로 적어둔 과거 내역은 그대로 남는다(프론트가 custom:<라벨> 키에서 라벨을 복원하므로 참조가 끊길 자리가 없다). UNIQUE(user_id, kind, label) + INSERT OR IGNORE 라 같은 이름을 두 번 넣어도 하나다. 라벨 12자·구분당 20개 제한 — 칩 한 줄에 들어가야 읽히고, 고르는 게 적는 것보다 오래 걸리면 안 된다. 라우터에서 /user/ledger/categories 가 /user/ledger 보다 먼저 걸린다.
- 2026-08-19: 가계부 수정 — PUT /user/ledger?id= 추가. 저장(POST)과 몸통·검증이 같아 _readEntry 로 합쳤다(두 곳에 같은 규칙을 두면 한쪽만 고쳐지는 날이 온다). 소유권은 삭제와 같은 방식으로 UPDATE ... WHERE id = ? AND user_id = ? 이고, 바뀐 행이 없으면 404. created_at 은 건드리지 않으므로 UPDATE 뒤 다시 읽어 그대로 실어 보낸다. PUT 이 유효해지면서 405 테스트는 PATCH 로 옮겼다.
- 2026-08-18: 가계부 /user/ledger 추가(0024 ledger_entries). GET ?month=YYYY-MM 은 그 달 내역만, POST 는 한 건 추가, DELETE ?id= 는 한 건 삭제. 삭제 id 를 body 가 아니라 쿼리로 받는 이유: 프론트 프록시가 non-GET body 에 주문 필드(PDNO·buyOrSell·ORD_QTY)를 끼워 넣어서다. amount 는 언제나 양수로 저장하고 부호는 kind(income|expense)가 정한다 — 합계에서 부호가 어긋날 자리를 아예 없앤다. 월 합계가 걸린 entry_date·kind·amount 만 검증하고 category 는 표시용이라 통과시킨다. 집계는 워커가 하지 않는다(그 달 내역이 이미 전부 내려가므로 화면에서 한 번 접는 편이 리스트와 항상 일치한다).
- 2026-08-18: /scan/daily 응답 meta 에 matched(조건에 맞는 **전체** 수) 추가. total 은 이 응답에 실린 행 수라, 화면이 "오늘 N개 발굴"을 total 로 세면 limit 을 줄이는 순간 숫자가 같이 줄어들었다 — 그래서 랜딩이 일곱 줄만 그리면서 2,500행(압축 전 1.3MB)을 받고 있었다. matched 는 limit 과 무관한 COUNT 다. 수집 중에는 목록에 어제 보완분이 섞이므로 개수도 같은 기준(오늘 + 어제 미스캔분)으로 센다.
- 2026-08-17: 맡은 돈이 곧 굴리는 돈이 됐다. 반기 시드가 1,000만원 고정에서 **그 시점의 AUM**으로 바뀌고(첫 반기 1억), 정산에서 그 반기 수익률이 AUM 에 그대로 곱해진 뒤 고객 유출입이 더해진다(nextAum: 성과 → 유출입 순. 유출입을 먼저 태우면 이번 반기에 없던 돈으로 번 셈이 된다). 대시보드에 "맡은 돈 1억"을 띄워 놓고 정작 1,000만원만 굴리던 괴리를 없앤다. 시드는 createReplayRoundD1 이 회사에서 직접 읽는다 — 부르는 쪽이 한 번이라도 빠뜨리면 그 반기만 조용히 1,000만원짜리가 된다. 캠페인 없는 판(체험 운용·옛 경로)만 예전 시드. MIN_AUM(1,000만) 하한 삭제 — 크게 잃으면 굴릴 돈도 줄어든 채로 간다(0 나눗셈만 막는 MIN_CAPITAL=1 로 대체). 이월분이 줄어든 자금보다 크면 자리를 비우지 않고 들어가는 만큼만 싣는다.
- 2026-08-16: 지난 분기 목록에 자리별 성적(stocks[])을 싣는다 — 종목·업종·실현손익·매수대금·체결 수. 넷을 한 줄로 묶으면 "이번 분기 +3%" 만 남아 어느 종목이 벌고 어느 종목이 까먹었는지가 사라진다. 이월한 자리는 자리 단위로 가린다(하나 이월했다고 정리한 셋까지 닫을 이유는 없다). 함께 고침: 마지막 날 강제 청산의 손익이 판 전체 realized 에만 더해지고 replay_holdings.realized 에는 안 들어가, 손 놓고 끝낸 자리가 손익 0 으로 보였다.
- 2026-08-16: 매매(phase 2)와 시간(phase 3)을 갈라놓음 — POST trade 신설. 종목이 넷이 되면서 "사면 하루가 지난다"가 성립하지 않는다: 두 번째 종목을 살 때는 이미 다음 날이라 같은 날 넷을 만질 수 없다. trade 는 그날 종가로 체결만 하고 커서를 안 옮기고, advance 는 시간만 옮긴다(매매 인자는 옛 경로 호환으로 남김). 화면도 개요(네 종목 한눈에 + 판 전체 곡선, 시간은 여기서만)와 상세(한 종목 차트 + 사고팔기)로 갈라졌다.
- 2026-08-16: 한 반기에 네 종목(0023). 종목 하나짜리 판은 "이 회사를 살까 말까"만 묻고 가진 돈을 어디에 얼마나 나눌 것인가를 못 묻는다. replay_holdings(round_id, slot 0..3)에 종목별 캔들·보유·원가·실현손익을 두고, 현금과 시드는 판에 하나로 넷이 나눠 쓴다. replay_orders.slot 이 어느 종목인지 가리킨다(옛 주문은 NULL → 0번으로 읽음). 네 종목은 같은 날짜 축을 쓴다 — services/replay.js 가 합집합 축을 만들고 거래가 없던 날은 직전 종가로 채운다(커서 하나가 넷에게 같은 날이어야 한다). 판의 candles 는 네 종목을 1/4 씩 담은 지수(equalWeightIndex)이고 벤치마크가 여기서 나온다: 종목이 넷이면 "그냥 사서 들고 있기"의 상대도 넷에 고르게 나눠 담은 것이어야 공정하다. 체결가는 언제나 그 자리 종목의 그날 종가다 — 지수로 체결하면 있지도 않은 가격에 사고팔게 된다(테스트로 고정). 예약·강제청산·이월도 자리마다. firms.carry_json 은 한 건짜리 객체에서 목록이 됐고 옛 기록은 한 건짜리 목록으로 읽는다.
- 2026-08-16: 캠페인 — 시작할 때 투자 기간(1·2·3·5·10·15·20년)을 고르면 그만큼 전으로 돌아가 달력 45일짜리 반기(1-1 … 4-2)를 차례로 굴려 오늘 근처까지 온다(0022). 반기를 달력으로 자른 이유: 거래일 45일로 잡으면 8반기가 1.44년이라 "N년 전으로 돌아가 N년을 굴린다"가 성립하지 않는다. 한 반기 창은 컨텍스트 30일 + 매매 45일 = 75일이라 KIS 일봉 한도(달력 100일) 안에 들어가 반기당 1회 호출로 끝난다. 판 길이와 컨텍스트 길이가 판마다 달라지므로(공휴일·연휴) 상수 대신 replay_rounds.context_days 에 실제 값을 적고, 벤치마크 기준일·매매 습관 계산이 그 값을 본다 — 0022 이전 판은 컬럼이 없어 그때 상수(20)로 읽는다. 판이 끝나면(중도 포기 포함) 캠페인이 한 칸 밀리고 마지막 반기면 status=done. 유저당 굴러가는 캠페인은 부분 유니크 인덱스로 하나만. POST start-campaign 신설, start 는 캠페인이 없으면 400.
- 2026-08-16: 이월한 분기는 지난 분기 목록(getReplayHistoryD1)에서도 ticker·name 을 가린다. 라운드 응답(_publicRound)에서 막아 둔 정답이 목록으로 새면 이어지는 판이 블라인드가 아니게 된다. 성적·기간·정산은 그대로 주고 carried 플래그를 실어 화면이 "아직 들고 있음"이라고 적게 한다.
- 2026-08-15: 리서치 도구 2종 추가(돌파선 20일 최고·최저 80만, 변동폭 14일 ATR 200만)와 도구마다 "어떻게 읽는가" 설명(hint). 둘 다 가격 축에 겹쳐 그릴 수 있는 것만 — 별도 영역이 필요한 지표를 넣으면 모바일 한 화면이 깨진다. 함께 유출입·보수 계수를 export(FLOW_EXCESS_MULT·FLOW_LOSS_MULT·FLOW_MIN·FLOW_MAX·BASE_FEE_BP·PERF_FEE_PCT) — 화면이 "고객 돈이 왜 이만큼 움직였는지"를 설명할 때 숫자를 다시 적으면 규칙이 바뀔 때 설명만 옛말이 되므로, 식과 문장이 같은 상수를 본다(test/firm-rules.test.js 가 상수와 식이 갈라지지 않는지 고정).
- 2026-08-14: 리플레이 — 컬럼이 없으면 어느 마이그레이션이 빠졌는지 화면에 적는다
- 2026-08-14: 리플레이 — 다음 분기로 포지션 이월(0021). 이월한 분기는 정답을 봉인한다
- 2026-08-14: 리플레이 — 예약 주문 지정가·손절·익절(0020). 갭이면 시가 체결
- 2026-08-14: 리플레이 — 업종 공개 + 판 성격 고르기(0019)
- 2026-08-11: 매매 습관 — 판에서 관찰된 사실을 보여준다(0018). "성향 진단"이 아니라 "습관"인 이유: 40일 한 판에 체결 두세 건으로 유형을 단정하면 근거 없는 확신이고 다음 판에 정반대로 나온다. 유형·칭호를 붙이지 않고 관찰값만 내며, 말할 수 없는 건 null 로 둬서 화면이 "아직 알 수 없음"이라고 적는다(처분효과는 이익 매도와 손실 매도가 각각 하나 이상 있어야 계산). 지표는 회전율·보유일(FIFO 매칭), 진입 타이밍(매수 직전 5거래일 수익률 → 추격 ↔ 저가매수), 처분효과(이익/손실 평균 보유일 차), 투입 강도·관망 비율. 마지막 날 강제 청산은 플레이어의 선택이 아니라 replay_orders.auto 플래그로 표시해 전부에서 제외한다. 계산은 워커에서만 하고 결과를 replay_rounds.habits_json 에 저장 — 프론트에 규칙 사본이 늘지 않고(engine·round·firm 세 쌍이 이미 있다), 누적 요약이 캔들을 재파싱하지 않으며, 규칙이 바뀌어도 지난 기록은 그때 값 그대로다. 누적은 체결 수로 가중(두 번 거래한 판과 스무 번 거래한 판을 같은 무게로 두면 왜곡된다). GET /user/replay 가 habits 요약을 함께 준다.
- 2026-08-11: 모의투자를 "내 운용사" 컨셉으로 — firms 테이블(0017) + 분기 정산. 한 판은 여전히 시드 1,000만 고정이라 실력만 재고, 그 성적으로 고객 자금(AUM)이 들고 난다. 시드를 고정하면서 AUM 이 의미를 갖는 이유: 성적은 실력이고 AUM 은 그 실력이 벌어들이는 것(실제 운용사 트랙레코드와 같은 구조). 규칙은 src/lib/firmRules.js 한 곳 — 자금 유출입 flow% = 초과수익×3 + min(수익률,0)×1.5 를 [-40,+50] 로 자르고, 운용보수 AUM×0.25%/분기 + 성과보수 초과수익분의 10% 를 회사 자금으로 적립한다. AUM 하한 1,000만(파산 없음), 등급은 AUM 에서 파생. 회사 자금으로 리서치 도구(이동평균선·볼린저밴드)를 사면 다음 판 차트에 실제로 나타난다. 정산 결과(aum_before/after, fee_base/perf)는 replay_rounds 에도 남긴다 — 규칙이 바뀌어도 지난 기록은 그때 값 그대로여야 한다. 쓸 데가 없던 coins 적립은 보수가 대체하며 제거(coinsFor 삭제, game_wallet.best_return 갱신은 유지). 도구 중복·초과 구매는 CHECK (cash >= 0) 와 조건부 UPDATE 의 meta.changes 판정으로 막는다. GET /user/replay 가 firm 을 함께 주고 POST 에 buy-tool·rename-firm 추가.
- 2026-08-10: /user/replay 응답에 체결 기록(orders) 추가 — 차트에 매매 시점·수량을 찍는 데 쓴다. replay_orders 에서 day_index/side/qty/price 만 뽑아 싣고 day_index < cursor 로 잘라 낸다(주문은 그날 종가로만 체결돼 언제나 과거지만, 이 함수가 미래를 흘리지 않는다는 걸 코드로 못박아 둔다). 라운드를 내보내는 경로는 전부 _publicRoundFull 하나로 모았다. 자동 청산도 기록으로 남아 마지막 캔들에 찍힌다.
- 2026-08-10: sql/0016_replay_rounds.dashboard.sql 추가 — Cloudflare 대시보드 D1 Console 에 붙여넣어 0016 을 손으로 적용하는 판. migrations/0016 과 스키마는 같고 두 가지만 다르다: (1) ALTER TABLE game_wallet ADD COLUMN best_return 은 SQLite 에 IF NOT EXISTS 가 없어 재실행 시 반드시 실패하므로 STEP 을 따로 떼고 "duplicate column name 은 정상"이라고 적어 뒀다, (2) d1_migrations 에 0015·0016 을 INSERT OR IGNORE 로 남긴다 — 빼먹으면 나중에 wrangler d1 migrations apply 가 둘 다 재실행해 방금 지운 paper_* 가 되살아난다(0015 도 적는 이유는 0016 이 0015 의 산물을 전부 DROP 하므로 결과 스키마가 "둘 다 돌린 상태"와 같기 때문). STEP 0/6 은 읽기 전용 확인 쿼리. test/dashboard-sql.test.js 가 이 파일과 migrations/0016 이 만드는 스키마를 주석·공백만 걷어내고 대조해 드리프트를 막는다(CHECK 제약이 비교에 남도록 원문 텍스트를 정규화해 비교).
- 2026-08-10: /user/replay 가 어떤 실패에서도 JSON 을 돌려주게 함 + /user/* 미매칭은 데모 HTML 대신 JSON 404. 워커만 배포하고 마이그레이션 0016 을 안 올린 상태에서 "한 판 시작"을 누르면 INSERT 가 no such table 로 던졌고, 라우트에 try/catch 가 없어 예외가 워커 밖으로 나가 Cloudflare 1101 오류 HTML 이 그대로 화면에 떴다. 이제 테이블이 없으면 "마이그레이션 0016 을 적용해주세요"가 뜬다(test/replay-route.test.js 가 고정). 겸사겸사 lib/d1Store.js 가 services/replay.js 를 가져오던 순환 참조를 src/lib/replayRules.js(leaf) 로 끊음 — lib -> services 는 레이어 방향이 거꾸로다.
- 2026-08-09: 모의투자를 블라인드 차트 리플레이로 바꿈 — replay_rounds·replay_orders(0016) + GET/POST /user/replay, 0015 의 paper_* 테이블은 DROP. 종목명과 시기를 가린 과거 60 거래일 일봉을 하루씩 넘기며 그날 종가로 사고팔고, 끝나면 수익률과 정답을 연다(앞 20일은 컨텍스트로 한 번에 공개, 나머지 40일 진행). 실시간 계좌는 정규장에만 주문이 돼 장이 닫히면 아무것도 못 하고 사놓고 다음 날까지 기다려야 해서 게임의 리듬이 없었다. 핵심 제약: 캔들 60개는 서버가 D1 에 쥐고 cursor 까지만 내보낸다 — 코인·최고기록이 서버에 남는 이상 브라우저에서 미래를 보면 그 기록이 거짓이 된다(d1Store._publicRound, test/replay-round.test.js 가 응답 전체를 훑어 미래 날짜가 안 새는지 고정). KIS 일봉은 캘린더 100일을 넘기면 조용히 주봉으로 바뀌므로(quotations.js diffDays 분기) 95일 폭으로 요청해 한 판을 KIS 1회 호출로 만든다. 종목은 stock_data_daily 에서 관리·정리매매·거래정지 아닌 것으로 뽑고 시작일은 6개월~5년 전 무작위(너무 최근이면 기억나서 블라인드가 무의미). 결과는 Buy & Hold 대비로 보여 주고 game_wallet 에 코인·best_return(0016 신규 컬럼) 적립. 매매 규칙(수수료·원가 배분)은 paperEngine.js 를 그대로 쓰되 isMarketOpen 은 제거 — 리플레이엔 실시간 축이 없다.
- 2026-08-09: 모의투자 계좌 추가 — paper_accounts·paper_positions·paper_orders(0015) + GET/POST /user/paper. 가상 시드 1,000만원으로 매수·매도하고 보유·거래내역을 계정별로 저장한다(로그인 필요). action=order{side,ticker,qty} 는 정규장(09:00~15:30 KST)에만 받고 체결가는 KIS inquire-price 실시간 현재가를 주문당 1회 조회해 잡는다 — stock_data_daily.last_price 는 롤링 스캔값이라 최대 12시간 지연이고 그걸로 체결하면 이미 오른 걸 아는 종목을 옛 가격에 살 수 있다. 평가금액 조회(GET)는 D1 조인만 쓰므로 KIS 호출 0회. 수수료 매수·매도 각 0.015% + 증권거래세 0.18%, 전부 정수 원 연산(0.00015 를 곱하면 700000*0.00015===104.99999… 라 1원이 샌다). 평단가를 저장하지 않고 cost_basis(총 매입금액)+qty 만 저장해 부분 매도 시 원가가 새지 않게 했고, 잔고·수량 음수는 스키마 CHECK 로 막는다 — 조건부 UPDATE 가 0행을 바꾸는 건 오류가 아니라서 뒤 문장이 그대로 실행돼 공짜 주식이 생기기 때문. 규칙은 src/lib/paperEngine.js 한 곳에 모으고 test/paper-engine.test.js·test/paper-store.test.js(node:sqlite 로 실제 SQL 실행)로 고정. 주의: app/api/proxy 가 모든 non-GET body 에 buyOrSell 을 끼워 넣고 값이 없으면 "sell" 로 채우므로 이 라우트는 side 만 읽는다.
- 2026-08-06: 관심 종목 목록(getStockLikesWithDataD1)에도 0013·0014 종목정보를 실어 보낸다. stock_data_daily 를 이미 LEFT JOIN 하고 있었으나 SELECT 에 신규 컬럼이 없어 관심 목록에서는 업종·위험 플래그를 볼 수 없었다. 관심 목록에서 가장 값진 정보는 "담아둔 종목이 관리종목·정리매매가 됐는가"이므로 sector·market·acml_tr_pbmn·w52_hgpr·w52_lwpr·stat_cls_code·temp_stop_yn·mang_issu_cls_code·sltr_yn·invt_caful_yn·short_over_yn·mrkt_warn_cls_code 를 함께 반환. 미수집 종목은 LEFT JOIN 이라 전부 null 로 떨어지고, 화면은 null 을 "값 없음"으로 처리해 아무것도 그리지 않는다.
- 2026-08-06: /scan/daily 응답에 0013·0014 로 수집한 종목정보를 실어 보낸다 (scan.js). 컬럼은 D1 에 있고 스캐너도 채우고 있었지만 목록 쿼리의 SELECT 에 없어 프론트까지 도달하지 못했고, 스크리너의 업종·유동성·거래정지·관리종목 필터가 "데이터 없음"으로 판단해 화면에서 통째로 숨어 있었다. 추가 필드: sector·market·acml_tr_pbmn·w52_hgpr·w52_lwpr·stat_cls_code·temp_stop_yn·mang_issu_cls_code·sltr_yn·invt_caful_yn·short_over_yn·mrkt_warn_cls_code. 화면에서 안 쓰는 acml_vol·vol_tnrt·frgn_ehrt·prdy_ctrt 는 응답 크기(최대 2,500행) 때문에 제외. 오늘 쿼리와 수집 중 보완 쿼리가 같은 목록을 쓰도록 _LIST_COLUMNS 상수로 묶고 test/scan-columns.test.js 로 고정 — 한쪽만 고치면 수집 중 시간대에만 필드가 사라진다.
- 2026-08-05: 종목 위험 플래그 수집 (migration 0014, ncavScanner.js). inquire-price 응답의 mang_issu_cls_code(관리종목여부)·sltr_yn(정리매매)·invt_caful_yn(투자유의)·short_over_yn(단기과열)·mrkt_warn_cls_code(시장경고: 00 없음/01 투자주의/02 투자경고/03 투자위험)를 함께 적재. 이미 호출하는 응답이라 KIS 호출 수는 늘지 않는다. 0013 의 stat_cls_code(iscd_stat_cls_code)는 종목당 값이 하나뿐이라(51 관리종목/58 거래정지/55 신용가능…) 관리종목이면서 신용가능인 종목은 둘 중 하나만 실려 온다 → "관리종목 제외"는 전용 플래그로 판단해야 한다. 값 도메인을 단정하지 않고 KIS 원문 문자열을 그대로 저장한다(mrkt_warn_cls_code 만 코드표가 문서화돼 있음). 코드표 출처: koreainvestment/open-trading-api 컬럼 매핑 + EFriendExpert 스펙 미러.
- 2026-08-05: D1 마이그레이션을 대시보드에서 적용 (/dashboard/migrate). Workers 에는 파일시스템이 없어 wrangler.jsonc 의 rules(type:"Text")로 migrations/*.sql 을 번들에 싣고, src/migrations.js 목록으로 순서를 잡는다(디렉터리와 목록이 어긋나면 test/migrate.test.js 가 실패). 적용 이력은 wrangler 와 같은 d1_migrations 테이블에 같은 스키마·같은 이름(파일명)으로 남겨 CLI 와 서로의 결과를 알아본다. 실행 대상은 커밋된 마이그레이션 파일뿐 — 임의 SQL 입력창은 두지 않았다(원격 SQL 콘솔이 되어 유출 시 DROP TABLE 까지 열린다). 인증은 전용 시크릿 MIGRATION_TOKEN(X-Migration-Token 헤더)이며 미설정 시 503 으로 잠긴다. /dashboard/* 에는 인증이 없고 워커의 admin 판정은 X-User-Id 헤더 기반이라 위조 가능해, 되돌릴 수 없는 DDL 을 그 위에 얹지 않았다(별도 과제).
- 2026-08-04: 스크리너 수집 시 종목정보도 함께 누적 (migration 0013, ncavScanner.js). (1) inquire-price 응답에 이미 있으나 버리던 값을 stock_data_daily 에 적재 — 업종·시장구분·누적거래량·거래대금·거래량회전율·외국인소진율·전일대비율·52주최고/최저·종목상태(iscd_stat_cls_code)·거래정지여부. 스캔이 이미 호출하는 API 라 KIS 호출은 늘지 않는다. (2) search-stock-info 로만 얻는 정적 기본정보(상장일·상장폐지일·결산월일·액면가·자본금·상장주수·표준코드·영문명·코스피200여부·표준산업분류)를 stock_master(티커당 1행)에 적재. stock_master 에 이미 있는 티커는 호출하지 않으므로 2,000종목을 한 바퀴 채운 뒤에는 추가 호출이 사실상 0. 관리종목·거래정지처럼 변하는 상태는 건너뛰기로 낡으므로 master 가 아니라 (1)의 일별 컬럼에 둔다. 아울러 _archiveBatch(ncavScanKr.js)가 lstn_stcn 을 아카이브에 넘기지 않아 migration 0005 이후 한 번도 보존되지 않던 것을 새 컬럼과 함께 수정.
- 2026-07-28: GET /trading/account 에 목록 모드 추가 (accountManage.js). kakao-id 없이 호출하면 해당 country 의 등록된 자동매매 계정 목록을 반환한다(admin 전용). 기존엔 kakao-id 가 필수라 admin 이 어떤 계정이 자동매매 대상인지 조회할 방법이 없어, balance 페이지에서 전체 카카오 로그인 이력 중 하나를 찍어 고르는 수밖에 없었음. 응답은 단건 조회와 동일하게 appsecret 미반환·appkey 마스킹. kakao-id 를 주는 기존 단건 조회 동작은 그대로.
- 2026-07-27: 미사용 코드 정리 — d1Store.js 에서 호출처가 없는 export 3건(updateUserLastLoginD1·getActiveAccountsD1·getUserIdByProviderAccountIdD1), conditions.js 의 미사용 임포트 2건 제거. finance.js 의 (true == x ?? false) 에 괄호를 넣어 esbuild suspicious-nullish-coalescing 경고를 0건으로 정리(실행되지 않는 블록이라 동작 변화 없음). docs.js 는 HTML 전체가 템플릿 리터럴 하나라 changelog 에 백틱을 쓰면 빌드가 깨지므로, 렌더 테스트로 고정.
- 2026-07-27: MainForBacktest 최초 실행 시 스캔 인덱스가 NaN 이 되던 버그 수정 (trading/backtest.js). ?? 가 + 보다 우선순위가 낮아 loop + info?.backtest_index ?? 0 이 (loop + index) ?? 0 으로 묶였고, KV backtest_info 가 비어 있으면 0 + undefined = NaN 인데 NaN 은 nullish 가 아니라 폴백이 동작하지 않았음. all_tickers[NaN] 이 undefined 라 symbol=undefined 로 finnhub 을 5회 헛 호출한 뒤 backtest_index 에 null 을 저장했음(다음 실행부터 0 으로 자가 치유되므로 영향은 최초 1회). 괄호로 폴백을 되살림.
- 2026-07-27: DELETE /ticker-map/:ticker 가 삭제된 행이 없어도 항상 성공(deleted:true)으로 응답하던 버그 수정 (admin/tickerMap.js). D1 run() 의 변경 행 수는 meta.changes 인데 result.changes(항상 undefined)로 읽어 404 분기가 죽어 있었음. accountStatus.js 와 같은 원인. 아울러 단위 테스트 도입(npm test, node 내장 러너라 새 의존성 없음) — D1 스텁이 실제 반환 형태를 그대로 흉내내므로 같은 실수가 재발하면 테스트가 실패한다.
- 2026-07-27: 계정별 자동매매 on/off(PATCH /trading/account-status)가 D1 UPDATE 성공을 인식하지 못하던 버그 수정 — D1 run() 의 변경 행 수는 meta.changes 인데 result.changes(항상 undefined)로 읽어 항상 레거시 분기로 떨어졌다. 그 결과 계정별 토글이 성공해도 전역 KV 스위치(TRADING_ACTIVE_KR/US)를 덮어썼다. 아울러 admin 이 kakao-id 로 특정 계정(예: syb2025)을 고른 경우에는 레거시 폴백을 타지 않고 404 를 반환하도록 변경 — 기존엔 해당 계정에 trading_accounts 행이 없어도 전역 스위치를 켜고 성공으로 응답해, 자동매매가 안 되는데 켜진 것처럼 보였다.
- 2026-07-19: 중복 카드 전환 코인이 등급 무관 최저값(explore)으로 고정되던 버그 재수정 — 이제 워커가 저장된 card_json 재무값(ncav·pbr·per·roe)으로 등급을 직접 산정(_toneFromFinancials, 프론트 valueScore.ts와 동일 로직). card_json.tone 유무와 무관하게 골드=12 등 등급대로 지급되고, 클라이언트가 보낸 tone을 신뢰하지 않아 조작도 방지(재무값 전무한 옛 카드만 요청 tone→저장 tone→explore 폴백).
- 2026-07-19: COIN_VALUE(d1Store.js) clay 2.5→3으로 조정해 소수점 값 제거(raw와 동일 가격 3).
- 2026-07-19: 중복 카드 전환 코인 가치(COIN_VALUE, d1Store.js) 등급별로 인상 — explore 1→2, clay 1.5→2.5, raw 2→3, iron 2.5→4, bronze 3→5, silver 5→8, gold 8→12, diamond 12→18, treasure 18→25, legend 30→40.
- 2026-07-19: convertDupesToCoinsD1(d1Store.js) 중복 카드 전환 시 코인이 항상 1로 고정되던 버그 수정 — card_json.tone이 없는 옛 카드(등급 필드 도입 전 수집분)는 explore로 폴백되던 것을, 전환 요청 body의 tone(프론트가 매번 재계산)을 우선 사용하도록 변경. /user/wallet convert 요청 파라미터에 tone 추가.
- 2026-07-19: 카드 게임 등급 10단계 확장(iron·clay 톤 추가) 반영 — COIN_VALUE(d1Store.js)에 iron=2.5·clay=1.5 코인 가치 추가(누락 시 explore 가로 폴백되던 문제 방지).
- 2026-07-18: game_wallet 테이블(0012) + GET/POST /user/wallet 추가 — 카드 게임 계정별 코인·최고 연승(best_streak) 저장. action=convert(중복 카드→코인 전환, 최소 1장 유지), spend(상점 구매용 코인 차감), syncBest(연승 기록을 기기 간 동기화)를 하나의 라우트에서 처리. d1Store getGameWalletD1/convertDupesToCoinsD1/spendCoinsD1/syncBestStreakD1, 코인 가치는 deck_cards.card_json 에 저장된 tone(프론트 전송)으로 조회.
- 2026-07-13: 자동매매 종목별 마지막 결과/사유 기록 (kr.js·us.js). 매 tick 종목의 매매 판단 결과를 stockEntry.last_result={at,code,msg} 로 저장해 capital_token 에 실림 → 현황 화면에서 왜 매매 안 되는지 조회 가능. code: pdno_unresolved(종목코드 미해결·오버라이드 추가 종목), no_price(현재가 조회 실패), insufficient_token(예산<1주값), condition_fail(NCAV 미달), no_financials(재무없음·DCA 아님), bought/sold(체결), order_error(KIS rt_cd), sell_no_holding. 프론트 tradingActivityPanel '현재 매매 대상' 표에 상태 컬럼 추가.
- 2026-07-10: 자동매매 계정(trading_accounts) 관리 API·UI 추가 (accountManage.js·d1Store.js·router.js, 프론트 tradingAccountPanel). balance 페이지 '계정' 섹션에서 선택 계정의 App Key/Secret·계좌번호·월예산·활성여부를 등록/수정/삭제(admin 전용). GET 은 appsecret 미반환·appkey 마스킹, 각 필드 미입력 시 기존값 유지, upsert 는 quant_rule_json·created_at 보존. 기존엔 D1 SQL 직접 삽입만 가능했음.
- 2026-07-10: admin 자동매매 상태/토글이 비숫자 kakao-id 계정(syb2025·syc73803842 등)을 대상으로 동작하도록 수정 (accountStatus.js). 기존엔 kakao-id 를 숫자일 때만 선택 계정으로 인정해, 비숫자 계정 선택 시 admin 본인 계정으로 폴백되던 버그(capital.js 는 PR#66 에서 이미 수정됨). 이제 선택 kakao-id 가 세션 본인 내부 id 가 아니면 그 계정을 대상으로 함(그룹 관리와 동일 규칙). ※ 실제 자동매매 실행은 해당 계정의 trading_accounts 행(appkey/appsecret/account_number, is_active=1) 등록 필요.
- 2026-07-10: 자동매매 리필 인덱스 범위 초과 예외처리 (capital.js·kr.js·us.js). 그룹 소속 종목 제거로 stock_list 가 줄면 저장된 refill_stock_index 가 범위를 벗어날 수 있어, 종목 제거·재분류 시 _clampRefillIndex 로 [0,length) 로 보정하고, kr/us 런타임에서도 음수·초과 인덱스를 정규화(((i%len)+len)%len)해 방어.
- 2026-07-10: 미장(US) 주문 거래소를 시세가 나온 거래소(EXCD)로 통일 (overseas/quotations.js·us.js). PriceDetail 이 현재가를 얻은 EXCD(NAS/NYS/AMS)를 응답 헤더(X-Resolved-Excd)로 노출하고, us.js 가 이를 stockEntry.excd 에 저장해 OVRS_EXCG_CD(NASD/NYSE/AMEX)로 매핑, OrderUs 에 전달. 기존엔 거래소 목록에 없는 종목(TLT/VOO/IAUM 등 ETF)을 무조건 AMEX 로 주문해, NASDAQ 상장 종목(TLT)은 거래소 불일치로 주문 거부되던 문제 해결. 저장된 excd 로 다음 tick 부터 재probe 없이 바로 조회.
- 2026-07-10: 미장(US) 매수 예산을 '체결 시점'에 차감하도록 정산 추가 (us.js). US 는 지정가 주문이라 미체결 가능 → 주문 접수 시엔 예약(차감)하되, 다음 tick 에 보유 매입금액(frcr_pchs_amt) 증가분(=실제 체결분)만 지출로 인정하고 미체결분은 예산(token)으로 복구(재시도). 예약 유지로 주문 stacking(같은 예산으로 매 tick 중복 주문) 방지. (국장은 시장가 주문이라 접수=체결이므로 변경 없음)
- 2026-07-10: 그룹 DCA(정액매수) 모드 추가 (capital.js·kr.js·us.js). 그룹에 dca=true 설정 시, 소속 종목을 NCAV·재무·콜워런트 조건 없이 예산(token)이 1주 값 이상 모이면 매수 — ETF 등 비-NCAV 종목(재무제표가 없어 NCAV=0 이 되던)을 정액 적립식으로 매매 가능. 그룹 예산이 비중을 통제. 그룹 수정 API에 dca 필드, 프론트 그룹 설정에 '정액매수(DCA)' 토글 + 헤더 '정액' 뱃지.
- 2026-07-10: 미장(US) 매매에 last-known-good condition 폴백 적용 (us.js) — KR 과 동일. 해외 시세(PriceDetail)/재무(Finnhub) 라이브 응답이 tomv=0·pbr/eps/bps 결측이면, 이미 활성화(그룹 활성·규칙 통과)된 종목이 매수 판단(NCAV·콜워런트)에서 막혀 예산만 쌓이고 매수가 안 되던 문제 해결. 결측 시 스캔 때 저장된 condition 으로 폴백하고, 0 값으로 저장 condition 을 덮어쓰지 않음. 재무 빈 응답에도 저장 condition 있으면 진행. 매수 수량·주문가·토큰 차감도 effLast 사용.
- 2026-07-10: 국장(KR) 매매 PDNO 해결을 symbol(종목코드) 우선으로 (kr.js). 기존엔 stockEntry.name 을 CORP_NAME_LIST 에서 찾아 PDNO 를 재도출해, name 오염·좋아요 복사 종목처럼 name 이 목록과 안 맞으면 idx=-1 로 skip 되어 국장 매매가 안 되던 문제 해결. KR NCAV 후보·좋아요 모두 symbol=6자리 종목코드이므로 그대로 PDNO 로 사용(US 와 동일 방식), 안 맞을 때만 name 폴백. 미해결 시 인덱스 전진 후 skip(무한 스킵 방지).
- 2026-07-10: 매매 대상을 '그룹 소속 종목'만으로 분리 (conditions.js makeStockList + capital.js). NCAV 자동발굴 후보를 매매용 stock_list 에 병합하던 것을 중단하고, 미지정 후보는 candidate_pool 로 별도 보관. stock_list 는 유효 그룹 소속 종목만 유지 → 자동매매·현황이 오직 그룹 종목만 반영. 그룹 배치/삭제/좋아요복사/삭제 시 두 풀을 group_id 기준으로 재분류(_repartitionPools). (프론트 '미지정' 섹션은 candidate_pool 을 읽도록 함)
- 2026-07-10: 자동매매 안정화 (kr.js/us.js). (1) 매수 루프 기본값 0→1 — env(KR/US_PURCHASE_LOOP) 미설정 시 매매가 조용히 꺼지던 문제 방지(명시적 0은 여전히 비활성). (2) KR: KIS 재무(BalanceSheet) 결측 시 직전 저장 condition 으로 폴백 — 재무 API 빈 값으로 (유동자산-부채)=0 이 되어 이미 검증된 활성 종목 매수가 막히던 문제 해소(결측값으로 last-known-good 을 덮어쓰지 않음).
- 2026-07-10: 자동매매 매수/매도 루프가 활성 종목(action="active")만 순회하도록 수정 (kr.js/us.js). 기존엔 stock_list 전체를 인덱스로 돌며 action 을 확인하지 않아, (1) 미지정/비활성 종목이 매매되고(그룹 활성화가 매수 경로에서 무효), (2) 활성 종목이 인덱스에 안 걸리면 거의 매수되지 않았음. 이제 비활성 종목은 건너뛰고 매 tick 활성 종목만 처리 → 그룹 활성화 실제 반영 + 매매 신뢰성 향상.
- 2026-07-10: 자동매매 활성화를 그룹 기준으로 복원 (conditions.js makeStockList). 미지정(group_id 없음) 종목의 '기본 그룹' 자동 활성화를 제거 — 활성 그룹(is_trading_active) 소속 종목만 active. 프론트 '미지정 = 제외' 표시 및 매매중 집계와 정확히 일치(워커가 미지정 자동발굴 종목까지 매매하던 불일치 해소)
- 2026-07-09: 그룹 미지정(group_id 없음) 종목을 '기본 그룹'으로 자동 활성화 (conditions.js makeStockList). 예산만 설정하면 NCAV 자동발굴 상위 active_count 종목이 활성·리필·매매되도록 — 미지정 종목은 무조건 inactive라 예산이 있어도 리필/매매가 안 되던 문제 해소. 프론트 '활성 대상' 표시와 일치
- 2026-07-09: 자동매매 현황 조회용 GET /kr(us)/capital/activity 추가 — 스케줄러가 IQ_PURCHASE_LOG에 남긴 최근 체결(매수/매도) 로그를 계정별 JSON으로 반환. 프론트 balance 페이지의 '자동매매 현황' 패널에서 사용
- 2026-07-08: 자동매매 매수 문턱을 고정 BUY_THRESHOLD(3만원/US 10만원) → 1주 값 기준으로 변경. kr.js는 remainingToken<현재가면 skip(가격 0 가드), us.js는 computeOrderQty<1이면 skip. 예산은 리필되는데 문턱 미달로 매수가 지연되던 문제 해소
- 2026-07-05: deck_cards 같은 종목 중복 수집 허용 — count 컬럼(0011) 추가, addDeckCardD1 UPSERT로 count+1, /user/deck POST 응답에 count 반환
- 2026-07-03: 자동매매 매수/매도 판단에서 env SELL_ALL_KR/SELL_ALL_US 완전 제거 (kr.js/us.js). US는 SELL_ALL_US=1이 매수 분기를 막아 매 틱 전량 매도하던 근본 원인. 이제 그룹 side 만 따르고, 매도 시 보유 전량 매도
- 2026-07-03: 자동매매 매수/매도를 그룹별 side 로 결정 (kr.js/us.js). 계좌 전역 action 대신 그룹.side 사용, 미설정·미지정은 기본 buy → 계좌 전역 sell로 전량 매도되던 문제 차단. capital.js 그룹 생성/수정에 side 추가
- 2026-07-03: deck_cards 테이블(0010) + GET/POST /user/deck 추가 — 카드 게임 보유 덱을 계정별로 저장(로그인 필요). d1Store getDeckCardsD1/addDeckCardD1
- 2026-07-03: capital.js — 계좌 선택 시 kakao-id가 비숫자 키(예: syb2025)면 admin 본인 계좌로 잘못 폴백돼 그룹이 계좌별로 구분 안 되던 버그 수정. 숫자 판별 대신 "세션 본인 id일 때만 providerAccountId로 매핑"하도록 변경
- 2026-07-01: kr.js — 자동매매 KR tick의 KIS 호출(InquireBalance·InquirePrice·BalanceSheet·IncomeStatement·OrderCash)에 계좌 appKey/appSecret 전달 (토큰↔키 불일치로 EGW00304→크래시→리필 유실되던 버그); kr.js/us.js — 매매 단계 예외에도 활성화·리필 결과를 D1에 저장하도록 try/catch 보강
- 2026-07-01: uapi.js/overseas/quotations.js — 해외 시세 조회(search-info·price-detail·dailyprice)도 토큰과 동일 appKey/appSecret 사용하도록 수정 (TSLA 등 미국 종목 검색 안 뜨던 문제)
- 2026-07-01: uapi.js/quotations.js — 국내 시세 조회(inquire-price·search-stock-info·chart)가 토큰과 다른(죽은 env) 키를 헤더에 써 EGW00304 나던 버그 수정. 토큰 발급에 쓴 appKey/appSecret를 데이터 호출에도 동일 전달
- 2026-07-01: uapi.js — 시세·재무 조회(analyze/검색)에서 개인 키 없을 때 env 대신 D1 admin 키로 폴백 (삼성전자 등 검색 결과 안 뜨는 문제 수정)
- 2026-06-30: scan(ncavScanner.js)·analyze(backtest.js) KIS 자격증명을 env 대신 D1 admin trading_account 키로 변경 (getAdminKisCredsD1); backtest.js 주석 노출 시크릿 제거
- 2026-06-30: conditions.js/kr.js/us.js — DCA 리필을 그룹별로 변경 (applyGroupRefill: group.budget_krw로 그룹별 충전, 미설정 시 계좌예산÷active그룹수 fallback)
- 2026-06-30: conditions.js — makeStockList 필터링을 그룹별 quant_rule로 적용 (그룹 규칙 우선, 없으면 글로벌; active_count과 동일한 fallback)
- 2026-06-30: capital.js — /stocks/remove 엔드포인트 추가 (미지정 등 여러 종목 원자적 일괄 삭제, body: tickers[])
- 2026-06-30: capital.js — stock/{ticker} 경로의 ticker를 decodeURIComponent로 복원 (한글 종목명 symbol 삭제·이동 불일치 버그 수정)
- 2026-06-29: capital.js — CapitalStockRemove에 user_excluded 블록리스트 추가; conditions.js — makeStockList에서 user_excluded 종목 재추가 방지 (미지정 종목 삭제 버그 수정)
- 2026-06-29: conditions.js — 활성화 로직을 전체 top-N에서 그룹별 독립 top-N으로 수정 (토큰 리필 버그 수정); capital.js — CapitalGroupUpdate에 quant_rule 저장 지원 추가 (그룹별 트레이딩 조건 설정)
- 2026-06-29: capital.js — /stock/{ticker}/remove 엔드포인트 추가 (미지정 종목 stock_list에서 삭제); conditions.js — 미지정(group_id=null) 종목 자동매매 기본값 OFF로 변경
- 2026-06-28: capital.js — /token/reset/all, /token/reset/ticker/{ticker} 엔드포인트 추가 (토큰 0으로 리셋)
- 2026-06-28: capital.js + accountStatus.js — kakao-id 파라미터가 숫자형일 때만 선택 계정으로 사용, UUID 등 비숫자는 세션 providerAccountId로 폴백 (잔고 외 자동매매 기능 계좌별 동작 수정)
- 2026-06-27: /uapi 잔고·주문 자격증명을 trading_accounts(D1) 우선으로 변경 — appkey/appsecret/account_number(CANO)를 (user_id=kakaoId 또는 KI 전용 id, country)별 행에서 조회, 미등록 시 env(KOREA_INVESTMENT_API_ARRAY·CANO) 폴백. 주문 CANO도 account_number 우선. (잔고 조회 CANO는 후속)
- 2026-06-22: /scan/daily limit 상한 2000 → 2500 (전체 스캔 종목수 2400 초과 대응, 결과가 2000개로 잘리던 문제 수정)
- 2026-06-21: 자동매매 관리(quant-rule·월예산·계정 on/off)를 선택 계정 kakaoId 기준으로 변경 — admin이 계정 선택 후 해당 계정의 자동매매 설정 관리 가능. trading_accounts.user_id 규약=kakaoId 명시
- 2026-06-21: /kr(us)/capital — admin이 계정 선택(?kakao-id) 시 선택 계정으로 그룹·토큰 작업이 적용되도록 수정(기존엔 admin 본인 계정으로 고정되던 버그)
- 2026-06-21: scheduled 분기를 배타적으로 분배 — 아카이브(%5=3)/트레이딩(%5=0)/그 외 NCAV 스캔(36분/시간). 함수 간 CPU(10ms) 공유 방지 + 스캔 처리량 2배↑
- 2026-06-20: inquire-daily-itemchartprice — FID_COND_MRKT_DIV_CODE를 UN→J로 변경(NXT 미상장·저유동성 종목 차트 빈 응답 수정)
- 2026-06-19: GET /scan/daily — 수집 중 응답 meta에 prevDate(보완 데이터 기준일) 추가
- 2026-06-18: GET/POST /kr(us)/capital/budget — 자동매매 월 예산(총 리필량) 조회·수정 + 5분당/종목당 리필액 계산 반환
- 2026-06-18: GET/POST /user/withdraw-cooldown — 재가입 쿨다운 기간 조회(공개)·변경(관리자). KV CONFIG:WITHDRAW_COOLDOWN_DAYS
- 2026-06-18: 재가입 방지 — 탈퇴 시 카카오 식별자 해시 기록(withdrawn_users), POST /user/withdraw-status 로 가입 시 30일 쿨다운 확인
- 2026-06-18: DELETE /user/delete-account 신규 추가 — 회원 탈퇴(D1 users·accounts·trading_accounts·capital_tokens·stock_likes + KV 프로필·매매로그·리포트 삭제, payments 보존, 카카오 unlink best-effort, cf_token 만료)
- 2026-06-17: /algorithm/trade — 로그인 인증 추가(user.id 필수). 비로그인 요청은 'need to login' 반환 (개인 자본/매매 로그 보호)
- 2026-06-15: 좋아요 자동매매 제거(관심목록 전용) + POST /kr(us)/capital/likes/copy — 좋아요 종목을 실제 그룹으로 복사
- 2026-06-15: makeStockList 좋아요(__likes__) 종목 정리 — 좋아요 해제·그룹 OFF 시 운용 풀에서 자동 제거
- 2026-06-14: POST /kr(us)/capital/stocks/group (여러 종목 일괄 이동) 추가 + groups/create 가 tickers 동시 편입 지원
- 2026-06-14: GET·POST /kr(us)/capital/quant-rule 추가 — 계좌별 트레이딩 조건(quant_rule) 조회·수정 (trading_accounts.quant_rule_json)
- 2026-06-14: POST /kr(us)/capital/groups/* 및 /kr(us)/capital/stock/:ticker/group 추가 — 운용 종목 그룹 관리 + 그룹별/좋아요 그룹 자동매매 토글
- 2026-06-11: GET /scan/daily, /scan/daily/ticker/:ticker, /scan/portfolio — lstn_stcn 컬럼 추가 반환, /scan/portfolio candidates에 start_market_cap 추가 (주식 병합 조정 수익률 지원)
- 2026-06-11: GET /scan/portfolio fill-forward 방식 도입 — 스캔 누락 종목도 마지막 알려진 가격으로 포트폴리오 수익률에 포함
- 2026-06-10: GET /scan/portfolio/overview 신규 추가 — 전략별 전체 스캔 날짜 기준 현재가 대비 포트폴리오 수익률 요약 반환
- 2026-06-10: GET /scan/portfolio 신규 추가 — 기준일 후보 종목 균등 매수 시 일별 포트폴리오 수익률 시계열 반환 (stock_data_daily 14일 범위)
- 2026-06-09: 자동매매 스케줄러 — trading_accounts 미등록 시 IQ KV(TRADING_ACTIVE_KR/US)로 on/off 제어; is_active=0 계정 정확히 스킵
- 2026-06-09: GET/PATCH /trading/account-status — env var 기반 레거시 계정(kis_tokens) 지원, IQ KV로 상태 저장
- 2026-06-09: GET/PATCH /trading/account-status 신규 추가 — 국내/해외 자동매매 on/off 토글 (D1 trading_accounts.is_active)
- 2026-06-08: ticker-map API 추가 — 종목명 오버라이드 관리 (GET/POST/DELETE /ticker-map)
- 2026-06-07: GET /scan/daily 스캔 상태 조회 최적화 — 2 D1 reads → 1 GROUP BY 쿼리 + IQ KV 캐시(SCAN:DAILY:STATE, 60s/300s TTL)
- 2026-06-07: users.lastLoginAt 컬럼 추가 — 로그인 시마다 갱신, 어드민 페이지 표시. createdAt Unix 초→밀리초 변환 표시 수정
- 2026-06-07: GET/POST /user/likes 신규 추가 — 유저별 관심 종목 조회·토글 (D1 stock_likes 테이블, stock_data_daily JOIN)
- 2026-06-06: 프론트엔드 브랜드 아이덴티티 개편 — 액센트 컬러 terracotta → finance green(#16a34a), Pretendard 폰트 적용, 홈페이지 헤드라인 카피 변경
- 2026-06-06: GET /admin/users 신규 추가 — D1 users 테이블 전체 조회 (어드민 전용)
- 2026-06-04: D1 읽기 최적화 — /scan/daily·dates Cloudflare Cache API 5분 엣지 캐시, users 조회 in-memory 캐시(60s), SELECT * → 필요 컬럼만 조회
- 2026-06-04: /scan/daily limit 최대값 500 → 2000으로 상향 (전체 종목 조회 지원)
- 2026-06-03: /dashboard/scan 개선 - 다음/직전 스캔 배치 종목명·티커, 예상 완료 시각, 최근 스캔 결과 테이블 추가
- 2026-06-03: ROE 계산식 수정 - NCAV(유동자산-부채)를 분모로 쓰던 버그를 EPS/BPS(자본총계 기반)로 교정, s_rim 전략 태깅 동일 수정; migration 0005로 기존 stock_data_daily/archive 일괄 교정
- 2026-06-03: docs 페이지 개편 - /scan/* 섹션 신설, 섹션 우선순위 재정렬, 레거시 배지 추가
- 2026-06-02: ncav_daily double-write 제거, 전체를 stock_data_daily 기반으로 통합
- ncavScanKr: ncav_daily 쓰기 제거, stock_data_daily만 사용
- /ncav/daily, /ncav/archive: stock_data_daily/archive 기반으로 전환 (ncav 전략 필터 기본)
- /stock/:ticker: stock_data_daily 기반으로 전환, strategies·roe 필드 추가
- 2026-06-02: /scan/* API 신규 추가 (stock_data_daily · stock_data_archive 기반)
- GET /scan/daily, /scan/daily/dates, /scan/daily/ticker/:ticker
- GET /scan/archive, /scan/archive/ticker/:ticker
- GET /scan/status
- ?strategy=ncav|low_pbr|low_per|s_rim|all, ?period_type=monthly|weekly 지원
- 2026-06-02: 다중 전략 종목 데이터 누적 (stock_data_daily · stock_data_archive)
- 지원 전략: NCAV · 저PBR · 저PER · S-RIM (roe > 0.08 && pbr < 1.0)
- 모든 스캔 종목 저장 (기존 ncav_daily는 NCAV 종목만 저장하던 것을 확장)
- 3단 아카이브: 일별 14일 보관 → 주별(26주) → 월별(무기한)
- 롤링 아카이브: min%5===3 tick마다 50종목씩 처리 (10ms CPU 제약 대응)