@mulsok/traders-client 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +42 -5
  3. package/bin/cli.js +618 -38
  4. package/dist/server/auth/admin.js +32 -0
  5. package/dist/server/auth/admin.js.map +1 -0
  6. package/dist/server/broker/kiwoom/_helpers/symbol-code.js +21 -0
  7. package/dist/server/broker/kiwoom/_helpers/symbol-code.js.map +1 -0
  8. package/dist/server/broker/kiwoom/client.js +23 -4
  9. package/dist/server/broker/kiwoom/client.js.map +1 -1
  10. package/dist/server/broker/kiwoom/endpoints/account.js +14 -6
  11. package/dist/server/broker/kiwoom/endpoints/account.js.map +1 -1
  12. package/dist/server/broker/kiwoom/endpoints/quote.js +99 -13
  13. package/dist/server/broker/kiwoom/endpoints/quote.js.map +1 -1
  14. package/dist/server/broker/kiwoom/index.js +1 -1
  15. package/dist/server/broker/kiwoom/index.js.map +1 -1
  16. package/dist/server/broker/kiwoom/order-tracker.js +13 -0
  17. package/dist/server/broker/kiwoom/order-tracker.js.map +1 -1
  18. package/dist/server/broker/kiwoom/price-feed.js +13 -0
  19. package/dist/server/broker/kiwoom/price-feed.js.map +1 -1
  20. package/dist/server/broker/kiwoom/ws/endpoints/condition.js +3 -2
  21. package/dist/server/broker/kiwoom/ws/endpoints/condition.js.map +1 -1
  22. package/dist/server/calendar/kst-marker.js +68 -0
  23. package/dist/server/calendar/kst-marker.js.map +1 -0
  24. package/dist/server/config.js +370 -72
  25. package/dist/server/config.js.map +1 -1
  26. package/dist/server/db/sqlite.js +53 -29
  27. package/dist/server/db/sqlite.js.map +1 -1
  28. package/dist/server/diary/writer.js +151 -46
  29. package/dist/server/diary/writer.js.map +1 -1
  30. package/dist/server/index.js +166 -11
  31. package/dist/server/index.js.map +1 -1
  32. package/dist/server/jobs/universe-sync.js +48 -11
  33. package/dist/server/jobs/universe-sync.js.map +1 -1
  34. package/dist/server/jobs/watchdog.js +4 -0
  35. package/dist/server/jobs/watchdog.js.map +1 -1
  36. package/dist/server/journal/telemetry.js +11 -0
  37. package/dist/server/journal/telemetry.js.map +1 -1
  38. package/dist/server/journal/trade-journal.js +42 -16
  39. package/dist/server/journal/trade-journal.js.map +1 -1
  40. package/dist/server/llm/context-builder.js +9 -5
  41. package/dist/server/llm/context-builder.js.map +1 -1
  42. package/dist/server/personas/loader.js +46 -16
  43. package/dist/server/personas/loader.js.map +1 -1
  44. package/dist/server/personas/persona-agent.js +93 -21
  45. package/dist/server/personas/persona-agent.js.map +1 -1
  46. package/dist/server/personas/persona-state.js +198 -19
  47. package/dist/server/personas/persona-state.js.map +1 -1
  48. package/dist/server/personas/runner.js +29 -0
  49. package/dist/server/personas/runner.js.map +1 -1
  50. package/dist/server/personas/schema.js +13 -0
  51. package/dist/server/personas/schema.js.map +1 -1
  52. package/dist/server/personas/wake-plan.js +4 -67
  53. package/dist/server/personas/wake-plan.js.map +1 -1
  54. package/dist/server/readiness.js +70 -0
  55. package/dist/server/readiness.js.map +1 -1
  56. package/dist/server/routes.js +300 -13
  57. package/dist/server/routes.js.map +1 -1
  58. package/dist/server/screener/engine.js +110 -1
  59. package/dist/server/screener/engine.js.map +1 -1
  60. package/dist/server/screener/indicators.js +63 -0
  61. package/dist/server/screener/indicators.js.map +1 -0
  62. package/dist/server/server-sync.js +387 -126
  63. package/dist/server/server-sync.js.map +1 -1
  64. package/dist/server/share/postgres-publish.js +139 -0
  65. package/dist/server/share/postgres-publish.js.map +1 -0
  66. package/dist/server/share/prepare-state.js +50 -0
  67. package/dist/server/share/prepare-state.js.map +1 -0
  68. package/dist/server/share/prepare.js +61 -0
  69. package/dist/server/share/prepare.js.map +1 -0
  70. package/dist/server/share/publish-cron.js +96 -0
  71. package/dist/server/share/publish-cron.js.map +1 -0
  72. package/dist/server/share/snapshot.js +81 -0
  73. package/dist/server/share/snapshot.js.map +1 -0
  74. package/dist/server/watchers/condition-watcher.js +57 -16
  75. package/dist/server/watchers/condition-watcher.js.map +1 -1
  76. package/dist/web/assets/index-B1C-UX9W.js +88 -0
  77. package/dist/web/assets/index-N7Xwheka.css +1 -0
  78. package/dist/web/index.html +2 -2
  79. package/package.json +4 -5
  80. package/dist/web/assets/index-62SMpbaf.js +0 -79
  81. package/dist/web/assets/index-BPLQR0wt.css +0 -1
@@ -16,7 +16,8 @@
16
16
  import { loadConfig, saveConfig, SERVER_BASE_URL } from "./config.js";
17
17
  import { getConditionWatcher } from "./watchers/condition-watcher.js";
18
18
  import { readEvents, computeStats } from "./journal/trade-journal.js";
19
- import { readDiary } from "./diary/writer.js";
19
+ import { computeDailyPnl } from "./journal/pnl-stats.js";
20
+ import { readDiary, listDiariesForPersona } from "./diary/writer.js";
20
21
  import { getHoldings, getDeposit } from "./broker/kiwoom/index.js";
21
22
  import { loadState } from "./personas/persona-state.js";
22
23
  import { getCandles } from "./db/sqlite.js";
@@ -26,10 +27,22 @@ export function getLastSync() {
26
27
  return lastSync;
27
28
  }
28
29
  /* ─────────── 핵심: server ping 호출 ─────────── */
29
- export async function fetchServerSubscriptions() {
30
+ /**
31
+ * server `/api/client/ping` 호출 + cfg.auth.userId 갱신 + subscriptions 동기화.
32
+ *
33
+ * @param opts.allowBootstrap (default true) · 첫 ping (user_id null) 에서 응답으로
34
+ * user_id 받으면 즉시 한 번 더 self-recurse 해서 readiness payload 동봉한 두 번째
35
+ * ping 자동 trigger. 이를 끄면 single-shot (recurse 시 internal 로 false).
36
+ *
37
+ * onboarding UX bug 차단: 토큰 등록 직후 readiness 가 1시간 polling 까지 비어있어서
38
+ * web dashboard 의 Step 4 진행 안 되던 문제 해결 (2026-05-10 발견).
39
+ */
40
+ export async function fetchServerSubscriptions(opts = {}) {
41
+ const allowBootstrap = opts.allowBootstrap ?? true;
30
42
  const cfg = loadConfig();
31
43
  const token = cfg.auth?.deviceToken;
32
44
  const syncedAt = new Date().toISOString();
45
+ const wasFirstAuth = !cfg.auth?.userId;
33
46
  if (!token) {
34
47
  const result = {
35
48
  ok: false,
@@ -40,142 +53,253 @@ export async function fetchServerSubscriptions() {
40
53
  lastSync = result;
41
54
  return result;
42
55
  }
43
- // 클라이언트 readiness payload (server dashboard 표시)
44
- // ADR-008 익명 원칙: 종목 코드·가격·잔고·자격증명 송신 금지.
45
- // 메타·집계·자연어 (intent/reasoning 발췌) OK · 종목 코드는 마스킹 (4자리 'XX**').
46
- let readinessHeader;
47
- try {
48
- const cw = getConditionWatcher();
49
- const stats = cw.stats();
50
- const allWatchers = cw.list({ activeOnly: true });
51
- const activeIntents = allWatchers.slice(0, 8).map((w) => ({
52
- persona: w.personaSlug,
53
- intent: maskSymbolsInText(w.spec.intent),
54
- type: w.spec.condition.type,
55
- triggerCount: w.triggerCount,
56
- }));
57
- // 최근 7일 의사결정 통계 (페르소나별 buy/sell/skip)
58
- const stats7d = computeRecentStats(7);
59
- // 가장 최근 의사결정 1건 (reasoning 발췌 · 종목 마스킹)
60
- const lastDecision = getLatestDecision();
61
- // 페르소나별 오늘 일기 (있으면 마스킹된 본문)
62
- const today = todayKST();
63
- const diaries = {};
64
- for (const slug of cfg.subscriptions ?? []) {
65
- const d = readDiary(today, slug);
66
- if (d?.body)
67
- diaries[slug] = maskSymbolsInText(d.body);
68
- }
69
- // 최근 의사결정 5건 (배열)
70
- const recentDecisions = getRecentDecisions(5);
71
- // 성과 (PnL · 자본) — kiwoom + journal 합산
72
- const performance = await computePerformance();
73
- // 보유 종목 — kiwoom holdings (종목 마스킹)
74
- const holdings = await fetchHoldingsAnonymized();
75
- // 페르소나별 인스턴스 상태 (state.json) — 사용자 dashboard 전용
76
- // 종목 코드/이름 그대로 송신. 본인의 device_token 인증 readiness 라 본인만 봄.
77
- // 합산 텔레메트리 (다른 사용자 노출 가능 영역) ADR-008 마스킹 그대로 유지.
78
- // watchlist 의 각 종목에 daily_candles 의 최근 30일 종가 첨부 → dashboard sparkline.
79
- const instances = {};
80
- for (const slug of cfg.subscriptions ?? []) {
81
- try {
82
- const state = loadState(slug);
83
- const enrichedWatchlist = (state.watchlist ?? []).map((w) => {
84
- let recentCandles;
85
- let recentCloses;
86
- try {
87
- const rows = getCandles(w.code, 30); // DESC · 최대 30일
88
- if (rows.length > 0) {
89
- const ordered = [...rows].reverse(); // 시간순
90
- recentCandles = ordered.map((r) => ({
91
- date: r.date,
92
- open: r.open_krw,
93
- high: r.high_krw,
94
- low: r.low_krw,
95
- close: r.close_krw,
96
- volume: r.volume,
97
- }));
98
- recentCloses = ordered.map((r) => r.close_krw); // 하위 호환 (legacy clients)
99
- }
100
- }
101
- catch {
102
- // candles 미적재 종목 — 차트 없이 진행
103
- }
104
- return { ...w, recentCandles, recentCloses };
105
- });
106
- instances[slug] = {
107
- lastRunAt: state.lastRunAt,
108
- nextRunAt: state.nextRunAt,
109
- watchlist: enrichedWatchlist,
110
- positions: state.positions,
111
- stats: state.stats,
112
- };
56
+ // ping (cfg.auth.userId 미설정) 이면 readiness payload 보내지 않음.
57
+ // server token 으로 user_id 식별 후 응답 → 우리가 cfg 에 user_id 저장
58
+ // 이후 ping 부터 user-scoped 데이터 (LOCAL state.json ) 송신.
59
+ // 이렇게 해야 token 교체 직후 「이전 user 의 LOCAL data 가 새 user 의
60
+ // readiness 로 잘못 attribution」 되는 현상을 원천 차단.
61
+ const knownUserId = cfg.auth?.userId ?? null;
62
+ let readinessPayload = null;
63
+ if (knownUserId)
64
+ try {
65
+ const cw = getConditionWatcher();
66
+ const stats = cw.stats();
67
+ const allWatchers = cw.list({ activeOnly: true });
68
+ const activeIntents = allWatchers.slice(0, 8).map((w) => ({
69
+ persona: w.personaSlug,
70
+ intent: maskSymbolsInText(w.spec.intent),
71
+ type: w.spec.condition.type,
72
+ triggerCount: w.triggerCount,
73
+ }));
74
+ // 최근 7일 의사결정 통계 (페르소나별 buy/sell/skip)
75
+ const stats7d = computeRecentStats(7);
76
+ // 가장 최근 의사결정 1건 (reasoning 발췌 · 종목 마스킹)
77
+ const lastDecision = getLatestDecision();
78
+ // 페르소나별 오늘 일기 (있으면 마스킹된 본문)
79
+ const today = todayKST();
80
+ const diaries = {};
81
+ // 페르소나별 최근 7일 일기 archive — web /dashboard/diary 의 archive list 용
82
+ const recentDiaries = {};
83
+ for (const slug of cfg.subscriptions ?? []) {
84
+ const d = readDiary(today, slug);
85
+ if (d?.body)
86
+ diaries[slug] = maskSymbolsInText(d.body);
87
+ // 최근 7일 archive (오늘 포함)
88
+ const list = listDiariesForPersona(slug, 7);
89
+ if (list.length > 0) {
90
+ recentDiaries[slug] = list.map((entry) => ({
91
+ date: entry.date,
92
+ body: maskSymbolsInText(entry.body),
93
+ bodyLength: entry.body.length,
94
+ }));
95
+ }
113
96
  }
114
- catch (e) {
115
- console.warn(`[server-sync] persona state 읽기 실패: ${slug}`, e);
97
+ // 최근 의사결정 5건 (배열)
98
+ const recentDecisions = getRecentDecisions(5);
99
+ // 성과 (PnL · 자본) — kiwoom + journal 합산
100
+ const performance = await computePerformance();
101
+ // 보유 종목 — kiwoom holdings (종목 마스킹)
102
+ const holdings = await fetchHoldingsAnonymized();
103
+ // 페르소나별 인스턴스 상태 (state.json) — 사용자 dashboard 전용
104
+ // 종목 코드/이름 그대로 송신. 본인의 device_token 인증 readiness 라 본인만 봄.
105
+ // 합산 텔레메트리 (다른 사용자 노출 가능 영역) 는 ADR-008 마스킹 그대로 유지.
106
+ // watchlist 의 각 종목에 daily_candles 의 최근 30일 종가 첨부 → dashboard sparkline.
107
+ const instances = {};
108
+ for (const slug of cfg.subscriptions ?? []) {
109
+ try {
110
+ const state = loadState(slug);
111
+ const enrichedWatchlist = (state.watchlist ?? []).map((w) => {
112
+ let recentCandles;
113
+ let recentCloses;
114
+ try {
115
+ const rows = getCandles(w.code, 30); // DESC · 최대 30일
116
+ if (rows.length > 0) {
117
+ const ordered = [...rows].reverse(); // 시간순
118
+ recentCandles = ordered.map((r) => ({
119
+ date: r.date,
120
+ open: r.open_krw,
121
+ high: r.high_krw,
122
+ low: r.low_krw,
123
+ close: r.close_krw,
124
+ volume: r.volume,
125
+ }));
126
+ recentCloses = ordered.map((r) => r.close_krw); // 하위 호환 (legacy clients)
127
+ }
128
+ }
129
+ catch {
130
+ // candles 미적재 종목 — 차트 없이 진행
131
+ }
132
+ return { ...w, recentCandles, recentCloses };
133
+ });
134
+ // state.json positions[] 의 raw 필드명 (avgPriceKrw 등) 을 web 의
135
+ // InstanceState.positions[] type 에 맞춰 매핑.
136
+ // 매핑 안 하면 web /dashboard/[slug] 에서 p.avgCost.toLocaleString() crash.
137
+ const mappedPositions = (state.positions ?? []).map((p) => ({
138
+ code: p.code,
139
+ name: p.name,
140
+ quantity: p.quantity,
141
+ avgCost: p.avgPriceKrw
142
+ ?? p.avgBuyPriceKrw
143
+ ?? 0,
144
+ boughtAt: p.boughtAt,
145
+ // currentPrice / unrealizedPct / unrealizedKrw 는 broker holdings 에서 매칭 가능
146
+ // 시 채움 (현재 미구현 — web page 가 null 시 "—" 표시).
147
+ }));
148
+ instances[slug] = {
149
+ lastRunAt: state.lastRunAt,
150
+ nextRunAt: state.nextRunAt,
151
+ watchlist: enrichedWatchlist,
152
+ positions: mappedPositions,
153
+ stats: state.stats,
154
+ };
155
+ }
156
+ catch (e) {
157
+ console.warn(`[server-sync] persona state 읽기 실패: ${slug}`, e);
158
+ }
116
159
  }
160
+ // 디버그 — readiness ping payload 의 핵심 메트릭 stderr 로 출력 (web 측 0 표시
161
+ // 회귀 추적용 · 2026-05-09)
162
+ console.log(`[server-sync] readiness build · subs=${cfg.subscriptions?.length ?? 0} · ` +
163
+ `watchers active=${stats.active} · intents=${activeIntents.length} · ` +
164
+ `instances=${Object.keys(instances).length}`);
165
+ const readiness = {
166
+ // 기본
167
+ subscriptions_count: cfg.subscriptions?.length ?? 0,
168
+ active_watchers: stats.active,
169
+ kiwoom_connected: cfg.broker?.kind === "kiwoom" && !!cfg.broker?.appKey && !!cfg.broker?.appSecret,
170
+ llm_provider: cfg.llm?.provider ?? "none",
171
+ // 활동 메타
172
+ decisions_7d: stats7d.total,
173
+ decisions_by_action: stats7d.byAction,
174
+ decisions_by_persona: stats7d.byPersona,
175
+ last_decision_at: lastDecision?.timestamp ?? null,
176
+ last_decision: lastDecision
177
+ ? {
178
+ persona: lastDecision.persona,
179
+ action: lastDecision.action,
180
+ reasoning_excerpt: lastDecision.reasoning,
181
+ }
182
+ : null,
183
+ // 최근 5건 의사결정 (timeline 용)
184
+ recent_decisions: recentDecisions,
185
+ // 성과 (사용자 본인만 봄 · 자본 금액 OK · 종목·가격 송신 X)
186
+ performance,
187
+ // 보유 종목 (종목명 마스킹 · bp 손익률만)
188
+ holdings,
189
+ // watcher 자연어 의도
190
+ watcher_intents: activeIntents,
191
+ // 오늘 일기 (페르소나별)
192
+ today_diaries: diaries,
193
+ // 최근 7일 일기 archive (페르소나별 · web /dashboard/diary archive list 용)
194
+ recent_diaries: recentDiaries,
195
+ // 페르소나별 인스턴스 상태 (사용자 dashboard /dashboard/[slug] 표시용)
196
+ instances,
197
+ };
198
+ // POST body 로 송신 — Vercel header 크기 한계 (16KB) 우회.
199
+ readinessPayload = readiness;
200
+ }
201
+ catch (e) {
202
+ console.warn("[server-sync] readiness 계산 실패:", e);
117
203
  }
118
- const readiness = {
119
- // 기본
120
- subscriptions_count: cfg.subscriptions?.length ?? 0,
121
- active_watchers: stats.active,
122
- kiwoom_connected: cfg.broker?.kind === "kiwoom" && !!cfg.broker?.appKey && !!cfg.broker?.appSecret,
123
- llm_provider: cfg.llm?.provider ?? "none",
124
- // 활동 메타
125
- decisions_7d: stats7d.total,
126
- decisions_by_action: stats7d.byAction,
127
- decisions_by_persona: stats7d.byPersona,
128
- last_decision_at: lastDecision?.timestamp ?? null,
129
- last_decision: lastDecision
130
- ? {
131
- persona: lastDecision.persona,
132
- action: lastDecision.action,
133
- reasoning_excerpt: lastDecision.reasoning,
134
- }
135
- : null,
136
- // 최근 5건 의사결정 (timeline 용)
137
- recent_decisions: recentDecisions,
138
- // 성과 (사용자 본인만 봄 · 자본 금액 OK · 종목·가격 송신 X)
139
- performance,
140
- // 보유 종목 (종목명 마스킹 · bp 손익률만)
141
- holdings,
142
- // watcher 자연어 의도
143
- watcher_intents: activeIntents,
144
- // 오늘 일기 (페르소나별)
145
- today_diaries: diaries,
146
- // 페르소나별 인스턴스 상태 (사용자 dashboard /dashboard/[slug] 표시용)
147
- instances,
148
- };
149
- // HTTP 헤더는 ByteString (latin-1) — 한국어 그대로 못 들어감.
150
- // JSON 직렬화 후 \uXXXX 로 escape (서버 JSON.parse 가 자동 복원).
151
- readinessHeader = JSON.stringify(readiness).replace(/[\u0080-\uFFFF]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
152
- }
153
- catch (e) {
154
- console.warn("[server-sync] readiness 계산 실패:", e);
155
- }
156
204
  try {
157
205
  const url = `${SERVER_BASE_URL.replace(/\/$/, "")}/api/client/ping`;
206
+ // POST + JSON body — readinessPayload null 이어도 동봉 (서버는 무시).
158
207
  const res = await fetch(url, {
159
- method: "GET",
208
+ method: "POST",
160
209
  headers: {
161
210
  Authorization: `Bearer ${token}`,
162
211
  accept: "application/json",
163
- ...(readinessHeader ? { "X-Readiness": readinessHeader } : {}),
212
+ "content-type": "application/json",
164
213
  },
165
- signal: AbortSignal.timeout(7_000),
214
+ body: JSON.stringify({ readiness: readinessPayload }),
215
+ signal: AbortSignal.timeout(10_000),
166
216
  });
167
217
  if (!res.ok) {
168
218
  const text = await res.text().catch(() => "");
169
219
  throw new Error(`HTTP ${res.status} · ${text.slice(0, 80)}`);
170
220
  }
171
221
  const ping = (await res.json());
172
- // 인증 성공 시에만 subscriptions 동기화 (인증 실패 = 캐시 유지)
222
+ // 인증 결과 분기 (v2 schema):
223
+ // - 성공: server SSoT 로 user_id 확정. user_id 가 기존과 다르면 active
224
+ // user slot 만 전환 (이전 user 의 LOCAL fs / settings 는 자기 슬롯에
225
+ // 보존됨 — v2 핵심 격리). 메모리 singleton 만 reset.
226
+ // - 실패 (HTTP 200 + authenticated:false): server 가 명시 거부 →
227
+ // 로그아웃 의미. cfg.auth 비움 (users[*] 슬롯은 유지).
228
+ // (network error 는 catch 블록이 잡음 → 그대로)
173
229
  if (ping.authenticated) {
230
+ const newUserId = ping.user_id ?? null;
231
+ const newUserEmail = ping.user_email ?? undefined;
232
+ const prevUserId = cfg.auth?.userId ?? null;
233
+ const userChanged = !!newUserId && !!prevUserId && newUserId !== prevUserId;
234
+ if (userChanged) {
235
+ console.warn(`[server-sync] user 변경: ${prevUserId} → ${newUserId} · 메모리 singleton reset`);
236
+ // user 별 in-memory 상태들을 모두 reset. fs 의 user-scoped 데이터는
237
+ // v2 schema 가 자체 격리하므로 wipe 불필요.
238
+ try {
239
+ getConditionWatcher().clearAll();
240
+ }
241
+ catch { /* ignore */ }
242
+ try {
243
+ const { getOrderTracker, getPriceFeed, getKiwoomWs } = await import("./broker/kiwoom/index.js");
244
+ getOrderTracker().reset?.();
245
+ getPriceFeed().unsubscribeAll?.();
246
+ // broker 자격증명도 user 별로 다르므로 WS session 끊기 (새 user 의
247
+ // broker 자격증명으로 lazy reconnect 됨).
248
+ await getKiwoomWs().disconnect?.();
249
+ }
250
+ catch { /* ignore · 일부 헬퍼 미구현 OK */ }
251
+ try {
252
+ const { getTelemetry } = await import("./journal/telemetry.js");
253
+ getTelemetry().clearQueue?.();
254
+ }
255
+ catch { /* ignore */ }
256
+ }
257
+ // active user 의 subscriptions 갱신 — 항상 2-step.
258
+ // Step 1: auth 만 갱신 (saveConfig 가 settings 무시 → leak 차단)
259
+ // Step 2: 새 user 컨텍스트로 loadConfig 재실행 → 그 슬롯에 subs 기록
260
+ // 첫 로그인 / 동일 user / user 전환 모든 케이스 일관 처리.
174
261
  const slugs = (ping.subscriptions ?? [])
175
262
  .filter((s) => s.status === "live")
176
263
  .map((s) => s.persona_slug);
177
- const updated = { ...cfg, subscriptions: slugs.sort() };
178
- saveConfig(updated);
264
+ // Step 1: auth 갱신
265
+ saveConfig({
266
+ ...cfg,
267
+ auth: {
268
+ ...cfg.auth,
269
+ userId: newUserId ?? cfg.auth?.userId,
270
+ userEmail: newUserEmail ?? cfg.auth?.userEmail,
271
+ },
272
+ });
273
+ // Step 2: 활성 user 슬롯의 subscriptions 갱신
274
+ const fresh = loadConfig();
275
+ saveConfig({
276
+ ...fresh,
277
+ subscriptions: slugs.sort(),
278
+ });
279
+ // 첫 인증 (prevUserId null → newUserId 알게 됨) 또는 user 변경 후:
280
+ // 새 user 의 watchers 를 DB 에서 복원
281
+ if ((!prevUserId && newUserId) || userChanged) {
282
+ try {
283
+ await getConditionWatcher().restoreFromDb();
284
+ }
285
+ catch (e) {
286
+ console.warn("[server-sync] post-auth watcher restore 실패:", e);
287
+ }
288
+ }
289
+ }
290
+ else {
291
+ // server 가 token 거부 → 로그아웃 처리
292
+ // cfg.auth 비움 + 메모리 watchers 정리. users[*] 슬롯은 보존 (재로그인 대비)
293
+ if (cfg.auth?.userId || cfg.auth?.deviceToken) {
294
+ saveConfig({
295
+ ...cfg,
296
+ auth: { deviceToken: undefined, userId: undefined, userEmail: undefined },
297
+ });
298
+ try {
299
+ getConditionWatcher().clearAll();
300
+ }
301
+ catch { /* ignore */ }
302
+ }
179
303
  }
180
304
  const result = {
181
305
  ok: true,
@@ -184,6 +308,15 @@ export async function fetchServerSubscriptions() {
184
308
  ping,
185
309
  };
186
310
  lastSync = result;
311
+ // ── Self-bootstrap (onboarding UX fix · 2026-05-10) ──
312
+ // 첫 ping 은 cfg.auth.userId=null 이라 readiness payload 안 보냄.
313
+ // server 응답으로 user_id 받았으면 즉시 한 번 더 self-recurse 해서 readiness
314
+ // 동봉한 두 번째 ping. 이전엔 1시간 cron 까지 wait → web dashboard Step 4 진입 X.
315
+ // recursion depth 1 cap (allowBootstrap=false 로 재귀).
316
+ if (allowBootstrap && wasFirstAuth && ping.authenticated && ping.user_id) {
317
+ console.log(`[server-sync] bootstrap — 첫 인증 완료 (user_id=${ping.user_id}) · readiness 동봉 두 번째 ping 즉시 trigger`);
318
+ return await fetchServerSubscriptions({ allowBootstrap: false });
319
+ }
187
320
  return result;
188
321
  }
189
322
  catch (e) {
@@ -197,16 +330,124 @@ export async function fetchServerSubscriptions() {
197
330
  return result;
198
331
  }
199
332
  }
333
+ /* ─────────── Phase 5e-3 · 시세 incremental pull ─────────── */
334
+ import { upsertCandlesBulk, upsertUniverseBulk, } from "./db/sqlite.js";
335
+ /**
336
+ * vercel `/api/market/candles` 에서 자기 SQLite max(date) 이후 candle 만 pull.
337
+ *
338
+ * 첫 install 케이스 (max=00000000) 면 page loop 로 전체 download.
339
+ * 매일 호출 시 (max=어제) 면 1 page 로 ~3K row 빠르게 끝남.
340
+ *
341
+ * 멱등 — upsertCandlesBulk 가 PK (code,date) 기준 ON CONFLICT.
342
+ * 도중 실패 시 다음 호출이 같은 since 부터 재시작 (중복 다운로드 안전).
343
+ *
344
+ * onProgress 콜백 — 매 page 후 호출. prepare CLI / WebUI 의 progress bar source.
345
+ */
346
+ export async function pullCandlesFromServer(opts = {}) {
347
+ const cfg = loadConfig();
348
+ const token = cfg.auth?.deviceToken;
349
+ if (!token)
350
+ return { ok: false, error: "device_token 미설정" };
351
+ const { getDb } = await import("./db/sqlite.js");
352
+ const db = getDb();
353
+ const maxRow = db.prepare("SELECT MAX(date) AS d FROM daily_candles").get();
354
+ let since = maxRow.d ?? "00000000";
355
+ const PAGE_LIMIT = 200_000;
356
+ let totalAdded = 0;
357
+ let pages = 0;
358
+ const MAX_PAGES = 100; // 안전 cap (10.87M row / 200K = 55 page · 충분 마진)
359
+ for (let i = 0; i < MAX_PAGES; i++) {
360
+ const url = `${SERVER_BASE_URL}/api/market/candles?since=${since}&limit=${PAGE_LIMIT}`;
361
+ let res;
362
+ try {
363
+ res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
364
+ }
365
+ catch (e) {
366
+ return { ok: false, error: `네트워크 실패: ${String(e)}`, candlesAdded: totalAdded, pages };
367
+ }
368
+ if (!res.ok) {
369
+ return { ok: false, error: `HTTP ${res.status}`, candlesAdded: totalAdded, pages };
370
+ }
371
+ const body = (await res.json());
372
+ if (!body.ok) {
373
+ return { ok: false, error: "server 응답 ok=false", candlesAdded: totalAdded, pages };
374
+ }
375
+ if (body.candles.length > 0) {
376
+ upsertCandlesBulk(body.candles);
377
+ totalAdded += body.candles.length;
378
+ }
379
+ pages++;
380
+ opts.onProgress?.(pages, totalAdded, body.next_since);
381
+ if (!body.has_more)
382
+ break;
383
+ if (body.next_since === since)
384
+ break; // 안전: cursor 가 안 움직이면 무한 루프 방지
385
+ since = body.next_since;
386
+ }
387
+ return { ok: true, candlesAdded: totalAdded, pages };
388
+ }
389
+ /**
390
+ * vercel `/api/market/universe` 에서 전종목 마스터 full pull.
391
+ * universe 는 ~4,300 row 라 incremental 불필요 — 매번 full + upsert.
392
+ */
393
+ export async function pullUniverseFromServer() {
394
+ const cfg = loadConfig();
395
+ const token = cfg.auth?.deviceToken;
396
+ if (!token)
397
+ return { ok: false, error: "device_token 미설정" };
398
+ let res;
399
+ try {
400
+ res = await fetch(`${SERVER_BASE_URL}/api/market/universe`, {
401
+ headers: { Authorization: `Bearer ${token}` },
402
+ });
403
+ }
404
+ catch (e) {
405
+ return { ok: false, error: `네트워크 실패: ${String(e)}` };
406
+ }
407
+ if (!res.ok)
408
+ return { ok: false, error: `HTTP ${res.status}` };
409
+ const body = (await res.json());
410
+ if (!body.ok)
411
+ return { ok: false, error: "server 응답 ok=false" };
412
+ if (body.universe.length > 0) {
413
+ upsertUniverseBulk(body.universe);
414
+ }
415
+ return { ok: true, universeAdded: body.universe.length };
416
+ }
200
417
  /* ─────────── 주기 동기화 (서버 시작 시 + 1시간마다) ─────────── */
201
418
  let intervalTimer = null;
202
419
  export function startPeriodicSync(intervalMs = 3600_000) {
203
420
  if (intervalTimer)
204
421
  return;
205
422
  void fetchServerSubscriptions(); // 즉시 1회
423
+ // 시세 pull 도 startup + 매 interval 마다. 첫 install 첫 호출이 무거우니 별도 fire.
424
+ void runMarketPull();
206
425
  intervalTimer = setInterval(() => {
207
426
  void fetchServerSubscriptions();
427
+ void runMarketPull();
208
428
  }, intervalMs);
209
- console.log(`[server-sync] started · ${intervalMs / 1000}s interval`);
429
+ console.log(`[server-sync] started · ${intervalMs / 1000}s interval (subs + market pull)`);
430
+ }
431
+ async function runMarketPull() {
432
+ try {
433
+ const u = await pullUniverseFromServer();
434
+ if (!u.ok) {
435
+ console.warn(`[market-pull] universe 실패: ${u.error}`);
436
+ }
437
+ else if ((u.universeAdded ?? 0) > 0) {
438
+ console.log(`[market-pull] universe ${u.universeAdded} row upsert`);
439
+ }
440
+ const c = await pullCandlesFromServer();
441
+ if (!c.ok) {
442
+ console.warn(`[market-pull] candles 실패: ${c.error} (added=${c.candlesAdded ?? 0})`);
443
+ }
444
+ else if ((c.candlesAdded ?? 0) > 0) {
445
+ console.log(`[market-pull] candles ${c.candlesAdded} row · ${c.pages} page`);
446
+ }
447
+ }
448
+ catch (e) {
449
+ console.error("[market-pull] 예외:", e);
450
+ }
210
451
  }
211
452
  export function stopPeriodicSync() {
212
453
  if (intervalTimer)
@@ -356,10 +597,14 @@ async function computePerformance() {
356
597
  try {
357
598
  const from = new Date(Date.now() - 30 * 86400_000).toISOString();
358
599
  const stats = computeStats({ from });
359
- result.realized_pnl_krw_30d = Math.round(stats.realizedPnlKrw);
600
+ // computeStats.realizedPnlKrw 단순 sell-buy 차 (FIFO 미구현 · 매수만
601
+ // 있으면 음수로 잘못 표시). 정확한 FIFO 기반 실현 손익은 pnl-stats 의
602
+ // computeDailyPnl 사용.
360
603
  result.win_rate_pct = Math.round(stats.winRate * 10) / 10;
361
604
  result.total_decisions_30d = stats.totalDecisions;
362
605
  result.total_orders_30d = stats.totalOrders;
606
+ const pnl = computeDailyPnl({ from });
607
+ result.realized_pnl_krw_30d = Math.round(pnl.totalRealizedPnlKrw);
363
608
  }
364
609
  catch { /* ignore */ }
365
610
  // kiwoom 예수금 + holdings (broker 설정 시만)
@@ -367,8 +612,13 @@ async function computePerformance() {
367
612
  if (cfg.broker?.kind === "kiwoom" && cfg.broker?.appKey && cfg.broker?.appSecret) {
368
613
  try {
369
614
  const dep = await getDeposit();
370
- if (dep.ok)
371
- result.cash_krw = dep.data.totalDepositKrw;
615
+ if (dep.ok) {
616
+ // 키움 entr (= totalDepositKrw) 는 D+2 결제 전 cash. 매수 결제가 아직
617
+ // cash 에서 빠지지 않은 상태라 평가액과 더하면 매수 원금이 양쪽에
618
+ // 중복 계산됨 (회귀: 100만 입금 → 130만 표시). availableKrw (가용 cash,
619
+ // = D+2 결제 후 잔액 의미) 를 cash 의미로 사용.
620
+ result.cash_krw = dep.data.availableKrw || dep.data.totalDepositKrw;
621
+ }
372
622
  }
373
623
  catch { /* ignore */ }
374
624
  try {
@@ -405,6 +655,17 @@ async function fetchHoldingsAnonymized() {
405
655
  return [];
406
656
  }
407
657
  }
658
+ /**
659
+ * `<state_update>...</state_update>` 블록 제거 (ADR-014 agent guard).
660
+ * agent prompt 가 응답 끝에 zod 직렬화용 블록을 강제하는데, reasoning_excerpt 송신 시
661
+ * 그대로 포함되면 web dashboard 의 ReactMarkdown 이 unknown HTML 태그로 처리해
662
+ * 텍스트가 깨져 보임 (사용자 보고 2026-05-09).
663
+ */
664
+ function stripStateUpdate(text) {
665
+ return text.replace(/<state_update>[\s\S]*?<\/state_update>\s*$/i, "")
666
+ .replace(/<state_update>[\s\S]*?<\/state_update>/gi, "")
667
+ .trimEnd();
668
+ }
408
669
  function extractReasoningText(raw) {
409
670
  if (!raw)
410
671
  return "";
@@ -414,14 +675,14 @@ function extractReasoningText(raw) {
414
675
  const t = m ? m[1] : raw;
415
676
  const obj = JSON.parse(t);
416
677
  if (typeof obj.reasoning === "string")
417
- return obj.reasoning;
678
+ return stripStateUpdate(obj.reasoning);
418
679
  }
419
680
  catch { /* not JSON · markdown 또는 자유 텍스트 */ }
420
681
  // 2) "reasoning": "..." 단일 키 정규식 — 줄바꿈 보존
421
682
  const m2 = raw.match(/"reasoning"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"/);
422
683
  if (m2)
423
- return m2[1].replace(/\\n/g, "\n").replace(/\\"/g, '"');
424
- // 3) markdown · 자유 텍스트 응답 — 본문 그대로 반환 (호출부에서 cap 처리)
425
- return raw;
684
+ return stripStateUpdate(m2[1].replace(/\\n/g, "\n").replace(/\\"/g, '"'));
685
+ // 3) markdown · 자유 텍스트 응답 — <state_update> 블록 제거 본문만
686
+ return stripStateUpdate(raw);
426
687
  }
427
688
  //# sourceMappingURL=server-sync.js.map