@mulsok/traders-client 0.1.0 → 0.2.0

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