@mulsok/traders-client 0.1.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 (119) hide show
  1. package/README.md +103 -0
  2. package/bin/cli.js +160 -0
  3. package/bin/postinstall.js +57 -0
  4. package/bin/preuninstall.js +36 -0
  5. package/dist/server/broker/kiwoom/cache.js +86 -0
  6. package/dist/server/broker/kiwoom/cache.js.map +1 -0
  7. package/dist/server/broker/kiwoom/client.js +256 -0
  8. package/dist/server/broker/kiwoom/client.js.map +1 -0
  9. package/dist/server/broker/kiwoom/endpoints/_helpers.js +61 -0
  10. package/dist/server/broker/kiwoom/endpoints/_helpers.js.map +1 -0
  11. package/dist/server/broker/kiwoom/endpoints/account.js +448 -0
  12. package/dist/server/broker/kiwoom/endpoints/account.js.map +1 -0
  13. package/dist/server/broker/kiwoom/endpoints/detail.js +118 -0
  14. package/dist/server/broker/kiwoom/endpoints/detail.js.map +1 -0
  15. package/dist/server/broker/kiwoom/endpoints/investor.js +139 -0
  16. package/dist/server/broker/kiwoom/endpoints/investor.js.map +1 -0
  17. package/dist/server/broker/kiwoom/endpoints/order.js +134 -0
  18. package/dist/server/broker/kiwoom/endpoints/order.js.map +1 -0
  19. package/dist/server/broker/kiwoom/endpoints/quote.js +165 -0
  20. package/dist/server/broker/kiwoom/endpoints/quote.js.map +1 -0
  21. package/dist/server/broker/kiwoom/endpoints/ranking.js +180 -0
  22. package/dist/server/broker/kiwoom/endpoints/ranking.js.map +1 -0
  23. package/dist/server/broker/kiwoom/endpoints/sector.js +135 -0
  24. package/dist/server/broker/kiwoom/endpoints/sector.js.map +1 -0
  25. package/dist/server/broker/kiwoom/endpoints/theme.js +104 -0
  26. package/dist/server/broker/kiwoom/endpoints/theme.js.map +1 -0
  27. package/dist/server/broker/kiwoom/endpoints/universe.js +119 -0
  28. package/dist/server/broker/kiwoom/endpoints/universe.js.map +1 -0
  29. package/dist/server/broker/kiwoom/index.js +59 -0
  30. package/dist/server/broker/kiwoom/index.js.map +1 -0
  31. package/dist/server/broker/kiwoom/order-tracker.js +353 -0
  32. package/dist/server/broker/kiwoom/order-tracker.js.map +1 -0
  33. package/dist/server/broker/kiwoom/price-feed.js +119 -0
  34. package/dist/server/broker/kiwoom/price-feed.js.map +1 -0
  35. package/dist/server/broker/kiwoom/rate-limiter.js +97 -0
  36. package/dist/server/broker/kiwoom/rate-limiter.js.map +1 -0
  37. package/dist/server/broker/kiwoom/types.js +13 -0
  38. package/dist/server/broker/kiwoom/types.js.map +1 -0
  39. package/dist/server/broker/kiwoom/ws/client.js +370 -0
  40. package/dist/server/broker/kiwoom/ws/client.js.map +1 -0
  41. package/dist/server/broker/kiwoom/ws/endpoints/condition.js +146 -0
  42. package/dist/server/broker/kiwoom/ws/endpoints/condition.js.map +1 -0
  43. package/dist/server/broker/kiwoom/ws/realtime-bus.js +42 -0
  44. package/dist/server/broker/kiwoom/ws/realtime-bus.js.map +1 -0
  45. package/dist/server/broker/kiwoom/ws/types.js +19 -0
  46. package/dist/server/broker/kiwoom/ws/types.js.map +1 -0
  47. package/dist/server/broker/news.js +34 -0
  48. package/dist/server/broker/news.js.map +1 -0
  49. package/dist/server/bundle.js +43 -0
  50. package/dist/server/bundle.js.map +1 -0
  51. package/dist/server/calendar/krx-holidays.js +162 -0
  52. package/dist/server/calendar/krx-holidays.js.map +1 -0
  53. package/dist/server/config.js +263 -0
  54. package/dist/server/config.js.map +1 -0
  55. package/dist/server/db/sqlite.js +252 -0
  56. package/dist/server/db/sqlite.js.map +1 -0
  57. package/dist/server/diary/writer.js +266 -0
  58. package/dist/server/diary/writer.js.map +1 -0
  59. package/dist/server/index.js +316 -0
  60. package/dist/server/index.js.map +1 -0
  61. package/dist/server/jobs/universe-sync.js +132 -0
  62. package/dist/server/jobs/universe-sync.js.map +1 -0
  63. package/dist/server/jobs/watchdog.js +87 -0
  64. package/dist/server/jobs/watchdog.js.map +1 -0
  65. package/dist/server/journal/pnl-stats.js +108 -0
  66. package/dist/server/journal/pnl-stats.js.map +1 -0
  67. package/dist/server/journal/telemetry.js +174 -0
  68. package/dist/server/journal/telemetry.js.map +1 -0
  69. package/dist/server/journal/trade-journal.js +239 -0
  70. package/dist/server/journal/trade-journal.js.map +1 -0
  71. package/dist/server/llm/anthropic.js +98 -0
  72. package/dist/server/llm/anthropic.js.map +1 -0
  73. package/dist/server/llm/claude-cli.js +204 -0
  74. package/dist/server/llm/claude-cli.js.map +1 -0
  75. package/dist/server/llm/context-builder.js +229 -0
  76. package/dist/server/llm/context-builder.js.map +1 -0
  77. package/dist/server/llm/gemini.js +86 -0
  78. package/dist/server/llm/gemini.js.map +1 -0
  79. package/dist/server/llm/index.js +36 -0
  80. package/dist/server/llm/index.js.map +1 -0
  81. package/dist/server/llm/openai.js +87 -0
  82. package/dist/server/llm/openai.js.map +1 -0
  83. package/dist/server/personas/executor.js +318 -0
  84. package/dist/server/personas/executor.js.map +1 -0
  85. package/dist/server/personas/loader.js +165 -0
  86. package/dist/server/personas/loader.js.map +1 -0
  87. package/dist/server/personas/persona-agent.js +386 -0
  88. package/dist/server/personas/persona-agent.js.map +1 -0
  89. package/dist/server/personas/persona-state.js +170 -0
  90. package/dist/server/personas/persona-state.js.map +1 -0
  91. package/dist/server/personas/runner.js +162 -0
  92. package/dist/server/personas/runner.js.map +1 -0
  93. package/dist/server/personas/schema.js +123 -0
  94. package/dist/server/personas/schema.js.map +1 -0
  95. package/dist/server/personas/wake-plan.js +313 -0
  96. package/dist/server/personas/wake-plan.js.map +1 -0
  97. package/dist/server/readiness.js +414 -0
  98. package/dist/server/readiness.js.map +1 -0
  99. package/dist/server/routes.js +1216 -0
  100. package/dist/server/routes.js.map +1 -0
  101. package/dist/server/safety.js +153 -0
  102. package/dist/server/safety.js.map +1 -0
  103. package/dist/server/screener/engine.js +856 -0
  104. package/dist/server/screener/engine.js.map +1 -0
  105. package/dist/server/server-sync.js +427 -0
  106. package/dist/server/server-sync.js.map +1 -0
  107. package/dist/server/signing.js +39 -0
  108. package/dist/server/signing.js.map +1 -0
  109. package/dist/server/watchers/condition-watcher.js +519 -0
  110. package/dist/server/watchers/condition-watcher.js.map +1 -0
  111. package/dist/server/watchers/types.js +16 -0
  112. package/dist/server/watchers/types.js.map +1 -0
  113. package/dist/web/assets/index-62SMpbaf.js +79 -0
  114. package/dist/web/assets/index-BPLQR0wt.css +1 -0
  115. package/dist/web/index.html +14 -0
  116. package/package.json +93 -0
  117. package/scripts/com.mulsok.traders.client.plist.template +58 -0
  118. package/scripts/install-daemon.sh +156 -0
  119. package/scripts/uninstall-daemon.sh +62 -0
@@ -0,0 +1,1216 @@
1
+ import fs from "node:fs";
2
+ import { z } from "zod";
3
+ import { loadConfig, saveConfig, maskedConfig, mergeConfigPatch, DEFAULT_CONFIG, CONFIG_PATHS, SERVER_BASE_URL, } from "./config.js";
4
+ import { createLlmProvider } from "./llm/index.js";
5
+ import { fetchCommonBundle, fetchPersonaBundle, assembleFullPrompt } from "./bundle.js";
6
+ import { runReadinessCheck } from "./readiness.js";
7
+ import { buildLayer3Header, loadActivePatches } from "./llm/context-builder.js";
8
+ import { logDecision } from "./journal/trade-journal.js";
9
+ import { getConditionWatcher } from "./watchers/condition-watcher.js";
10
+ import { getPriceFeed, getOrderTracker } from "./broker/kiwoom/index.js";
11
+ import { computeStats, listJournalFiles, readEvents } from "./journal/trade-journal.js";
12
+ import { getTelemetry } from "./journal/telemetry.js";
13
+ import { getStockUniverse, getStandardUniverse, getMarketIndex, getAllSectorIndices, getSectorCodes, getForeignActivity, getInstitutionActivity, getContinuousInvestorActivity, getVolumeSurgeRanking, getYesterdayVolumeRanking, getDailyTradeDetail, getDailyStockPrice, listThemeGroups, getThemeMembers, getAccountAssessment, getExecutionBalance, getAccountDailyStatus, getAccountProfitRate, getTradeDiary, listConditions, requestCondition, getKiwoomWs, } from "./broker/kiwoom/index.js";
14
+ import { countUniverse, getSyncMeta, listUniverse, vacuumWatchersOlderThan, watchersTableStats } from "./db/sqlite.js";
15
+ import { runUniverseSync } from "./jobs/universe-sync.js";
16
+ import { runScreener } from "./screener/engine.js";
17
+ import { loadPersona, reloadPersona, PERSONA_OVERRIDE_DIR } from "./personas/loader.js";
18
+ import { listAvailablePersonas, runPersonaScreening } from "./personas/runner.js";
19
+ // SPEC-27 (ADR-011): executePersonaDecision 폐기 → executePersonaCycle (lazy import)
20
+ import { fetchServerSubscriptions, getLastSync } from "./server-sync.js";
21
+ import { writeDiary, readDiary, listDiariesForPersona } from "./diary/writer.js";
22
+ import { extractWakePlan, registerWakePlan } from "./personas/wake-plan.js";
23
+ export async function registerRoutes(app) {
24
+ // ── 헬스체크 ────────────────────────────────────────────────
25
+ app.get("/api/health", async () => {
26
+ const cfg = loadConfig();
27
+ return {
28
+ ok: true,
29
+ service: "@mulsok/client",
30
+ version: "0.0.0",
31
+ node: process.version,
32
+ config: {
33
+ present: fs.existsSync(CONFIG_PATHS.file),
34
+ path: CONFIG_PATHS.file,
35
+ provider: cfg.llm.provider,
36
+ server_base: SERVER_BASE_URL,
37
+ },
38
+ };
39
+ });
40
+ // ── Config ──────────────────────────────────────────────────
41
+ app.get("/api/config", async () => {
42
+ const cfg = loadConfig();
43
+ return { config: maskedConfig(cfg), default: DEFAULT_CONFIG };
44
+ });
45
+ /**
46
+ * POST /api/config · partial merge 의미.
47
+ *
48
+ * 규칙 (자세한 정의는 `mergeConfigPatch` 참조):
49
+ * - patch 에 없는 최상위 섹션은 **기존 유지** (default 리셋 X)
50
+ * - 비밀값: 빈 문자열·미포함·마스킹 sentinel → 기존 유지
51
+ * - 공개값 (provider · model · broker.kind · isDemo · halted): 명시 시 교체
52
+ */
53
+ app.post("/api/config", async (req, reply) => {
54
+ const body = (req.body ?? {});
55
+ const existing = loadConfig();
56
+ try {
57
+ const merged = mergeConfigPatch(existing, body);
58
+ const saved = saveConfig(merged);
59
+ return { ok: true, config: maskedConfig(saved) };
60
+ }
61
+ catch (e) {
62
+ const issues = e.issues;
63
+ reply.code(400);
64
+ return { ok: false, error: "스키마 오류", issues };
65
+ }
66
+ });
67
+ // ── Readiness Check · 거래 준비 상태 종합 ──────────────────
68
+ app.get("/api/readiness", async () => {
69
+ return runReadinessCheck();
70
+ });
71
+ // ── Setup Wizard · 초기 설정 5단계 검증 ──────────────────
72
+ // 사용자가 webui 에서 거래 준비 완료 까지 단계별 진단.
73
+ // 각 step: { ok, label, detail, action? }
74
+ app.get("/api/setup/wizard", async () => {
75
+ const fs = await import("node:fs");
76
+ const path = await import("node:path");
77
+ const { spawn } = await import("node:child_process");
78
+ // monorepo root 검출: cwd 부터 위로 .git 디렉토리 찾기
79
+ const findGitRoot = (start) => {
80
+ let cur = start;
81
+ const fsLocal = fs;
82
+ while (cur !== "/" && cur !== "") {
83
+ if (fsLocal.existsSync(path.join(cur, ".git")))
84
+ return cur;
85
+ cur = path.dirname(cur);
86
+ }
87
+ return start;
88
+ };
89
+ const projectRoot = process.env.MULSOK_PROJECT_ROOT ?? findGitRoot(process.cwd());
90
+ const steps = [];
91
+ // Step 1: Claude CLI 설치
92
+ const claudeOk = await new Promise((resolve) => {
93
+ const child = spawn("claude", ["--version"]);
94
+ let out = "";
95
+ child.stdout.on("data", (d) => { out += d.toString(); });
96
+ child.on("close", (code) => resolve({ ok: code === 0, version: out.trim() }));
97
+ child.on("error", () => resolve({ ok: false }));
98
+ setTimeout(() => resolve({ ok: false }), 5000);
99
+ });
100
+ steps.push({
101
+ id: "claude_cli",
102
+ ok: claudeOk.ok,
103
+ label: "Claude 도구",
104
+ detail: claudeOk.ok ? `준비됨 · ${claudeOk.version ?? ""}` : "Claude 명령줄 도구를 찾지 못했어요",
105
+ action: claudeOk.ok ? undefined : "claude.com/claude-code 에서 설치",
106
+ });
107
+ // Step 2: trader-tools Skill 존재
108
+ const skillPath = path.join(projectRoot, ".claude/skills/trader-tools/SKILL.md");
109
+ const skillOk = fs.existsSync(skillPath);
110
+ steps.push({
111
+ id: "skill_present",
112
+ ok: skillOk,
113
+ label: "내장 도구 모듈",
114
+ detail: skillOk ? "준비됨" : "도구 모듈이 누락됐어요",
115
+ action: skillOk ? undefined : "앱을 다시 설치하세요",
116
+ });
117
+ // Step 3: Skill helper scripts 실행 권한
118
+ const scriptsDir = path.join(projectRoot, ".claude/skills/trader-tools/scripts");
119
+ let scriptsOk = false;
120
+ let scriptsCount = 0;
121
+ if (fs.existsSync(scriptsDir)) {
122
+ const entries = fs.readdirSync(scriptsDir).filter((f) => f.endsWith(".sh"));
123
+ scriptsCount = entries.length;
124
+ scriptsOk = entries.every((f) => {
125
+ try {
126
+ const stat = fs.statSync(path.join(scriptsDir, f));
127
+ return (stat.mode & 0o111) !== 0;
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ });
133
+ }
134
+ steps.push({
135
+ id: "skill_scripts",
136
+ ok: scriptsOk && scriptsCount >= 5,
137
+ label: "도구 실행 권한",
138
+ detail: scriptsOk ? "준비됨" : "권한이 부족합니다",
139
+ action: scriptsOk ? undefined : `chmod +x ${scriptsDir}/*.sh`,
140
+ });
141
+ // Step 4: 키움 토큰 (account/status)
142
+ const cfg = loadConfig();
143
+ let kiwoomOk = false;
144
+ let kiwoomDetail = "증권사가 설정되지 않았어요";
145
+ if (cfg.broker.kind === "kiwoom" && cfg.broker.appKey && cfg.broker.appSecret) {
146
+ try {
147
+ const { getDeposit } = await import("./broker/kiwoom/index.js");
148
+ const r = await getDeposit();
149
+ kiwoomOk = r.ok;
150
+ kiwoomDetail = r.ok
151
+ ? `연결됨 · 예수금 ${r.data.totalDepositKrw.toLocaleString()}원`
152
+ : `연결 실패 — ${r.error.message}`;
153
+ }
154
+ catch (e) {
155
+ kiwoomDetail = `연결 오류 — ${String(e)}`;
156
+ }
157
+ }
158
+ steps.push({
159
+ id: "kiwoom_auth",
160
+ ok: kiwoomOk,
161
+ label: "증권사 계좌 연결",
162
+ detail: kiwoomDetail,
163
+ action: kiwoomOk ? undefined : "설정 탭에서 증권사 정보를 입력하세요",
164
+ });
165
+ // Step 5: 구독한 트레이더
166
+ const cfgForSubs = loadConfig();
167
+ const subs = cfgForSubs.subscriptions ?? [];
168
+ let subDetail;
169
+ let subOk = false;
170
+ if (subs.length === 0) {
171
+ subDetail = "구독한 트레이더가 없어요";
172
+ subOk = false;
173
+ }
174
+ else {
175
+ try {
176
+ const persona = await loadPersona(subs[0]);
177
+ subOk = !!persona;
178
+ subDetail = persona
179
+ ? `${subs.length}명 구독 중`
180
+ : "트레이더 정보를 불러오지 못했어요";
181
+ }
182
+ catch {
183
+ subDetail = "트레이더 정보를 불러오지 못했어요";
184
+ }
185
+ }
186
+ steps.push({
187
+ id: "persona_subscribed",
188
+ ok: subOk,
189
+ label: "구독한 트레이더",
190
+ detail: subDetail,
191
+ action: subOk ? undefined : "마켓에서 트레이더를 구독하세요",
192
+ });
193
+ const passedCount = steps.filter((s) => s.ok).length;
194
+ return {
195
+ ok: passedCount === steps.length,
196
+ passed: passedCount,
197
+ total: steps.length,
198
+ ready: passedCount === steps.length,
199
+ steps,
200
+ next: passedCount === steps.length
201
+ ? "거래 준비 완료 · /api/screen/<persona>/decide 호출 가능"
202
+ : `${steps.length - passedCount} 단계 남음`,
203
+ };
204
+ });
205
+ // ── Watchers (Phase 3 · LLM wake_plan.watchers 등록 · 모니터링) ──
206
+ // POST /api/watchers · LLM 응답에서 wake_plan.watchers 받아 등록
207
+ // GET /api/watchers · 활성 watcher 목록
208
+ // DELETE /api/watchers/:id · 단일 해제
209
+ // composite 는 MVP 에서 schema 제외 · ConditionWatcher.startComposite 도 미구현 (P3 후속)
210
+ const ConditionConfigSchema = z.discriminatedUnion("type", [
211
+ z.object({
212
+ type: z.literal("price"),
213
+ config: z.object({
214
+ symbolCode: z.string(),
215
+ metric: z.enum([
216
+ "current_price",
217
+ "change_pct",
218
+ "from_buy_price_pct",
219
+ "from_open_pct",
220
+ "volume_ratio_d1",
221
+ ]),
222
+ op: z.enum(["gte", "gt", "lte", "lt", "eq"]),
223
+ value: z.number(),
224
+ buyPriceKrw: z.number().positive().optional(),
225
+ checkIntervalMs: z.number().int().positive().optional(),
226
+ }),
227
+ }),
228
+ z.object({
229
+ type: z.literal("event"),
230
+ config: z.object({
231
+ symbolCode: z.string(),
232
+ metric: z.enum([
233
+ "ma_touch",
234
+ "ma_break_below",
235
+ "ma_break_above",
236
+ "bullish_engulfing",
237
+ "high_volume_bearish",
238
+ "previous_low_break",
239
+ "candle_volume_surge",
240
+ ]),
241
+ maPeriod: z.union([z.literal(5), z.literal(10), z.literal(20), z.literal(60), z.literal(120)]).optional(),
242
+ volumeMultiple: z.number().positive().optional(),
243
+ checkIntervalMs: z.number().int().positive().optional(),
244
+ }),
245
+ }),
246
+ z.object({
247
+ type: z.literal("time"),
248
+ config: z.object({
249
+ at: z.string().optional(),
250
+ afterMs: z.number().int().positive().optional(),
251
+ }),
252
+ }),
253
+ ]);
254
+ const ConditionSpecSchema = z.object({
255
+ intent: z.string().min(1),
256
+ condition: ConditionConfigSchema,
257
+ wakeAction: z.string().min(1),
258
+ maxWaitHours: z.number().positive().max(720),
259
+ repeat: z.boolean().optional(),
260
+ });
261
+ const RegisterWatchersSchema = z.object({
262
+ personaSlug: z.string().min(1),
263
+ /** LLM 출력에서는 wake_plan.watchers 배열 그대로 */
264
+ watchers: z.array(ConditionSpecSchema).max(20),
265
+ });
266
+ app.post("/api/watchers", async (req, reply) => {
267
+ const parsed = RegisterWatchersSchema.safeParse(req.body);
268
+ if (!parsed.success) {
269
+ reply.code(400);
270
+ return { ok: false, error: "스키마 오류", issues: parsed.error.issues };
271
+ }
272
+ const cw = getConditionWatcher();
273
+ const registered = [];
274
+ const rejected = [];
275
+ for (const spec of parsed.data.watchers) {
276
+ try {
277
+ registered.push(cw.add(parsed.data.personaSlug, spec));
278
+ }
279
+ catch (e) {
280
+ rejected.push({ intent: spec.intent, reason: String(e) });
281
+ }
282
+ }
283
+ if (rejected.length > 0 && registered.length === 0) {
284
+ reply.code(400);
285
+ return { ok: false, error: "모든 watcher 등록 거부", rejected };
286
+ }
287
+ return {
288
+ ok: true,
289
+ count: registered.length,
290
+ watchers: registered.map((w) => ({
291
+ id: w.id,
292
+ intent: w.spec.intent,
293
+ type: w.spec.condition.type,
294
+ expiresAt: w.expiresAt,
295
+ })),
296
+ ...(rejected.length > 0 ? { rejected } : {}),
297
+ };
298
+ });
299
+ app.get("/api/watchers", async (req) => {
300
+ const q = req.query;
301
+ const cw = getConditionWatcher();
302
+ const list = cw.list({
303
+ personaSlug: q.personaSlug,
304
+ activeOnly: q.activeOnly === "true",
305
+ });
306
+ // SQLite watchers 테이블 누적 통계 (P1-4 보강 · 모니터링용 · vacuum 시점 판단)
307
+ let dbStats = null;
308
+ try {
309
+ dbStats = watchersTableStats();
310
+ }
311
+ catch (e) {
312
+ // SQLite 일시 장애 등 — 응답에 영향 X
313
+ console.warn("[/api/watchers] watchersTableStats 실패:", e);
314
+ }
315
+ return {
316
+ stats: cw.stats(),
317
+ dbStats,
318
+ priceFeed: getPriceFeed().stats(),
319
+ watchers: list,
320
+ };
321
+ });
322
+ // ── Screening (범용 · 페르소나 frontmatter 기반) ──────────
323
+ //
324
+ // GET /api/screen/:personaId
325
+ // → loadPersona → frontmatter.screener → engine 실행
326
+ // → 페르소나 추가 시 코드 수정 0 (Supabase body frontmatter 또는 로컬 override)
327
+ //
328
+ // 이전 경로 /api/screening/neoul 도 동일 동작으로 유지 (호환).
329
+ app.get("/api/screen/:personaId", async (req, reply) => {
330
+ const { personaId } = req.params;
331
+ const r = await runPersonaScreening(personaId);
332
+ if (!r) {
333
+ reply.code(404);
334
+ return { ok: false, error: `persona '${personaId}' 를 찾을 수 없음 (Supabase 와 로컬 override 모두 미존재)` };
335
+ }
336
+ return { ok: true, ...r };
337
+ });
338
+ app.get("/api/screening/neoul", async (_req, reply) => {
339
+ // 레거시 alias · v1.9 에서 삭제 예정
340
+ reply.header("Deprecation", "true");
341
+ reply.header("Link", '</api/screen/neoul>; rel="successor-version"');
342
+ const r = await runPersonaScreening("neoul");
343
+ if (!r) {
344
+ reply.code(404);
345
+ return { ok: false, error: "persona 'neoul' 미존재" };
346
+ }
347
+ return { ok: true, ...r };
348
+ });
349
+ // ── Personas (발견 · 목록) ──────────────────────────────
350
+ app.get("/api/personas", async (req) => {
351
+ const q = req.query;
352
+ const slugs = (q.slugs ?? "neoul").split(",").map((s) => s.trim()).filter(Boolean);
353
+ const results = await listAvailablePersonas(slugs);
354
+ return { ok: true, items: results, overrideDir: PERSONA_OVERRIDE_DIR };
355
+ });
356
+ app.post("/api/personas/:personaId/reload", async (req, reply) => {
357
+ const { personaId } = req.params;
358
+ const r = await reloadPersona(personaId);
359
+ if (!r) {
360
+ reply.code(404);
361
+ return { ok: false, error: `persona '${personaId}' 리로드 실패` };
362
+ }
363
+ return { ok: true, persona: { slug: r.slug, version: r.frontmatter.version, source: r.source } };
364
+ });
365
+ // ── Persona agent cycle (SPEC-27 · ADR-011 stateful agent) ────
366
+ //
367
+ // POST /api/personas/:slug/cycle
368
+ // - claude-cli agent 가 자기 state.json/strategy.md read → 도구 호출 → 결정 → write
369
+ // - Layer 3 정적 조립 폐기 (agent 가 동적 read)
370
+ // - 이전 stateless `executePersonaDecision` 호출 폐기 (호환은 deprecation alias 유지)
371
+ app.post("/api/personas/:slug/cycle", async (req, reply) => {
372
+ const { slug } = req.params;
373
+ const body = (req.body ?? {});
374
+ const { executePersonaCycle } = await import("./personas/persona-agent.js");
375
+ const result = await executePersonaCycle(slug, {
376
+ kind: body.triggerKind ?? "manual",
377
+ intent: body.intent,
378
+ watcherId: body.watcherId,
379
+ });
380
+ if (!result.ok)
381
+ reply.code(result.stage === "persona" ? 404 : result.stage === "safety" ? 403 : 500);
382
+ return result;
383
+ });
384
+ // GET /api/personas/:slug/state — state.json read (UI · watchdog · 디버깅용)
385
+ app.get("/api/personas/:slug/state", async (req, reply) => {
386
+ const { slug } = req.params;
387
+ try {
388
+ const { loadState } = await import("./personas/persona-state.js");
389
+ return { ok: true, state: loadState(slug) };
390
+ }
391
+ catch (e) {
392
+ reply.code(500);
393
+ return { ok: false, error: String(e) };
394
+ }
395
+ });
396
+ // GET /api/personas/:slug/strategy — strategy.md read (UI · 사용자 노출용)
397
+ app.get("/api/personas/:slug/strategy", async (req, reply) => {
398
+ const { slug } = req.params;
399
+ try {
400
+ const { loadStrategy } = await import("./personas/persona-state.js");
401
+ return { ok: true, slug, strategy: loadStrategy(slug) };
402
+ }
403
+ catch (e) {
404
+ reply.code(500);
405
+ return { ok: false, error: String(e) };
406
+ }
407
+ });
408
+ // POST /api/orders — agent 도구: 안전 게이트 통과한 매수/매도
409
+ app.post("/api/orders", async (req, reply) => {
410
+ const body = (req.body ?? {});
411
+ if (!body.personaSlug || !body.symbolCode || !body.side || !body.quantity) {
412
+ reply.code(400);
413
+ return { ok: false, error: "personaSlug · symbolCode · side · quantity 필수" };
414
+ }
415
+ const { checkOrderSafety } = await import("./safety.js");
416
+ const safety = await checkOrderSafety({
417
+ personaSlug: body.personaSlug,
418
+ symbolCode: body.symbolCode,
419
+ side: body.side,
420
+ quantity: body.quantity,
421
+ priceKrw: body.priceKrw,
422
+ });
423
+ if (!safety.ok) {
424
+ reply.code(403);
425
+ return { ok: false, deniedCode: safety.code, reason: safety.reason };
426
+ }
427
+ try {
428
+ const { placeOrderWithTracking } = await import("./broker/kiwoom/index.js");
429
+ const placed = await placeOrderWithTracking({
430
+ symbolCode: body.symbolCode,
431
+ side: body.side,
432
+ quantity: body.quantity,
433
+ priceKrw: body.priceKrw,
434
+ type: body.priceKrw ? "limit" : "market",
435
+ }, { personaSlug: body.personaSlug });
436
+ if (!placed.ok) {
437
+ return { ok: false, error: placed.error.message };
438
+ }
439
+ return {
440
+ ok: true,
441
+ clientOrderId: placed.data.clientOrderId,
442
+ brokerOrderNo: placed.data.brokerOrderNo,
443
+ status: placed.data.status,
444
+ };
445
+ }
446
+ catch (e) {
447
+ reply.code(500);
448
+ return { ok: false, error: String(e) };
449
+ }
450
+ });
451
+ // ── (Deprecated · ADR-011) stateless decide alias ────────────
452
+ // 기존 호출자 호환만. 새 코드는 /api/personas/:slug/cycle 사용.
453
+ app.post("/api/screen/:personaId/decide", async (req, reply) => {
454
+ const { personaId } = req.params;
455
+ reply.header("Deprecation", "true");
456
+ reply.header("Link", `</api/personas/${personaId}/cycle>; rel="successor-version"`);
457
+ const { executePersonaCycle } = await import("./personas/persona-agent.js");
458
+ const result = await executePersonaCycle(personaId, { kind: "manual" });
459
+ if (!result.ok)
460
+ reply.code(result.stage === "persona" ? 404 : 500);
461
+ return result;
462
+ });
463
+ // ── wake_plan 파싱 + 등록 테스트 (LLM 없이 로직만 검증) ──
464
+ app.post("/api/test/wake-plan", async (req, reply) => {
465
+ const body = (req.body ?? {});
466
+ if (!body.llmResponseText) {
467
+ reply.code(400);
468
+ return { ok: false, error: "llmResponseText 필수" };
469
+ }
470
+ const persona = body.personaSlug ?? "neoul";
471
+ const extracted = extractWakePlan(body.llmResponseText);
472
+ if (!extracted.ok || !extracted.wakePlan) {
473
+ return { ok: false, stage: "parse", error: extracted.error, rawJson: extracted.rawJson };
474
+ }
475
+ if (body.dryRun) {
476
+ return { ok: true, dryRun: true, wakePlan: extracted.wakePlan };
477
+ }
478
+ const reg = registerWakePlan(persona, extracted.wakePlan);
479
+ return {
480
+ ok: true,
481
+ wakePlan: extracted.wakePlan,
482
+ registered: reg.registered.length,
483
+ skipped: reg.skipped.length,
484
+ nextScheduledRegistered: reg.nextScheduledRegistered,
485
+ watchers: reg.registered.map((w) => ({ id: w.id, intent: w.spec.intent, expiresAt: w.expiresAt })),
486
+ skipReasons: reg.skipped.map((s) => s.reason),
487
+ };
488
+ });
489
+ app.post("/api/screening/neoul/decide", async (_req, reply) => {
490
+ // 이중 레거시 alias (SPEC-27 ADR-011 후 폐기)
491
+ reply.header("Deprecation", "true");
492
+ reply.header("Link", '</api/personas/neoul/cycle>; rel="successor-version"');
493
+ const { executePersonaCycle } = await import("./personas/persona-agent.js");
494
+ const result = await executePersonaCycle("neoul", { kind: "manual" });
495
+ if (!result.ok)
496
+ reply.code(result.stage === "persona" ? 404 : 500);
497
+ return result;
498
+ });
499
+ // ── Safety · Emergency Stop (SPEC-26) ──
500
+ // GET /api/safety · 현 상태 (halted · 사유)
501
+ // POST /api/safety/halt · 응급 정지 활성 (모든 주문/LLM 호출 차단)
502
+ // POST /api/safety/resume · 정지 해제
503
+ app.get("/api/safety", async () => {
504
+ const cfg = loadConfig();
505
+ return { halted: cfg.halted ?? false };
506
+ });
507
+ app.post("/api/safety/halt", async (req) => {
508
+ const body = (req.body ?? {});
509
+ const cfg = loadConfig();
510
+ saveConfig({ ...cfg, halted: true });
511
+ console.warn(`[safety] HALTED · reason: ${body.reason ?? "(no reason)"}`);
512
+ return { ok: true, halted: true, reason: body.reason ?? null };
513
+ });
514
+ app.post("/api/safety/resume", async () => {
515
+ const cfg = loadConfig();
516
+ saveConfig({ ...cfg, halted: false });
517
+ console.log(`[safety] resumed`);
518
+ return { ok: true, halted: false };
519
+ });
520
+ // ── Allocations · 페르소나별 배정 자본 (SPEC-26) ──
521
+ // GET /api/allocations · 전체
522
+ // POST /api/allocations · body: { slug, krw } 한 페르소나 set
523
+ // DELETE /api/allocations/:slug · 한 페르소나 제거
524
+ app.get("/api/allocations", async () => {
525
+ const cfg = loadConfig();
526
+ return { allocations: cfg.allocations ?? {} };
527
+ });
528
+ app.post("/api/allocations", async (req, reply) => {
529
+ const body = (req.body ?? {});
530
+ if (!body.slug || typeof body.krw !== "number" || body.krw < 0) {
531
+ reply.code(400);
532
+ return { ok: false, error: "slug + krw (>=0) 필요" };
533
+ }
534
+ const cfg = loadConfig();
535
+ const updated = { ...cfg, allocations: { ...(cfg.allocations ?? {}), [body.slug]: body.krw } };
536
+ saveConfig(updated);
537
+ return { ok: true, allocations: updated.allocations };
538
+ });
539
+ app.delete("/api/allocations/:slug", async (req) => {
540
+ const { slug } = req.params;
541
+ const cfg = loadConfig();
542
+ const next = { ...(cfg.allocations ?? {}) };
543
+ delete next[slug];
544
+ saveConfig({ ...cfg, allocations: next });
545
+ return { ok: true, allocations: next };
546
+ });
547
+ // ── Subscriptions (구독한 페르소나 목록) ──
548
+ //
549
+ // SSoT = server (mulsok-traders.vercel.app) `user_instances` 테이블 (ADR-001 + ADR-009).
550
+ // 클라이언트는 device_token 으로 server `/api/client/ping` 호출 → subscriptions[] 캐시.
551
+ //
552
+ // GET /api/subscriptions · { subscriptions, source, lastSync, authenticated, ... }
553
+ // - 캐시 우선 응답 + 필요 시 sync trigger (백그라운드)
554
+ // POST /api/subscriptions/sync · 강제 server 재동기화
555
+ //
556
+ // ⚠️ POST /api/subscriptions (Mock 추가) 와 DELETE 는 폐기 (사용자가 임의로 추가 못 함).
557
+ app.get("/api/subscriptions", async (req) => {
558
+ const cfg = loadConfig();
559
+ const last = getLastSync();
560
+ const q = req.query;
561
+ // fresh=1 이면 즉시 동기화 후 응답
562
+ if (q.fresh === "1") {
563
+ const sync = await fetchServerSubscriptions();
564
+ const cfg2 = loadConfig();
565
+ return {
566
+ subscriptions: cfg2.subscriptions ?? [],
567
+ source: sync.authenticated ? "server" : "cache",
568
+ lastSync: sync.syncedAt,
569
+ authenticated: sync.authenticated,
570
+ userEmail: sync.ping?.user_email ?? null,
571
+ deviceLabel: sync.ping?.device_label ?? null,
572
+ details: sync.ping?.subscriptions ?? null,
573
+ error: sync.error ?? null,
574
+ };
575
+ }
576
+ return {
577
+ subscriptions: cfg.subscriptions ?? [],
578
+ source: last?.authenticated ? "server" : "cache",
579
+ lastSync: last?.syncedAt ?? null,
580
+ authenticated: last?.authenticated ?? false,
581
+ userEmail: last?.ping?.user_email ?? null,
582
+ deviceLabel: last?.ping?.device_label ?? null,
583
+ details: last?.ping?.subscriptions ?? null,
584
+ error: last?.error ?? null,
585
+ };
586
+ });
587
+ app.post("/api/subscriptions/sync", async () => {
588
+ const sync = await fetchServerSubscriptions();
589
+ const cfg = loadConfig();
590
+ return {
591
+ ok: sync.ok,
592
+ subscriptions: cfg.subscriptions ?? [],
593
+ authenticated: sync.authenticated,
594
+ lastSync: sync.syncedAt,
595
+ userEmail: sync.ping?.user_email ?? null,
596
+ details: sync.ping?.subscriptions ?? null,
597
+ error: sync.error ?? null,
598
+ };
599
+ });
600
+ // ── Diary (페르소나 매매 일기) ──
601
+ //
602
+ // GET /api/diary/:slug?date=YYYY-MM-DD · 특정 날짜 일기 (없으면 404)
603
+ // GET /api/diary/:slug/list?limit=30 · 최근 일기 목록 (date · 발췌)
604
+ // POST /api/diary/:slug/write · 오늘 일기 강제 작성 (overwrite)
605
+ app.get("/api/diary/:slug", async (req, reply) => {
606
+ const { slug } = req.params;
607
+ const q = req.query;
608
+ const date = q.date ?? new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10);
609
+ const d = readDiary(date, slug);
610
+ if (!d) {
611
+ reply.code(404);
612
+ return { ok: false, error: `${date} 의 ${slug} 일기 없음` };
613
+ }
614
+ return { ok: true, ...d };
615
+ });
616
+ app.get("/api/diary/:slug/list", async (req) => {
617
+ const { slug } = req.params;
618
+ const q = req.query;
619
+ const limit = q.limit ? parseInt(q.limit, 10) : 30;
620
+ const list = listDiariesForPersona(slug, limit);
621
+ return {
622
+ ok: true,
623
+ diaries: list.map((d) => ({
624
+ date: d.date,
625
+ personaSlug: d.personaSlug,
626
+ writtenAt: d.writtenAt,
627
+ excerpt: d.body.split("\n").slice(0, 6).join("\n").slice(0, 400),
628
+ bodyLength: d.body.length,
629
+ })),
630
+ };
631
+ });
632
+ app.post("/api/diary/:slug/write", async (req, reply) => {
633
+ const { slug } = req.params;
634
+ const body = (req.body ?? {});
635
+ const r = await writeDiary(slug, { overwrite: body.overwrite });
636
+ if (!r.ok) {
637
+ reply.code(500);
638
+ return r;
639
+ }
640
+ return r;
641
+ });
642
+ // ── Account Status (계좌 종합 상태) ─────────────────────
643
+ // 사용자 5 항목 검토용 통합 endpoint · 토큰·예수금·보유·미체결·체결 한 번에
644
+ // GET /api/account/status?symbolCode=005930 (옵션)
645
+ app.get("/api/account/status", async (req) => {
646
+ const q = req.query;
647
+ const cfg = loadConfig();
648
+ if (cfg.broker.kind !== "kiwoom") {
649
+ return {
650
+ ok: false,
651
+ error: "broker.kind != 'kiwoom' · 설정 탭에서 키움 선택 필요",
652
+ broker_kind: cfg.broker.kind,
653
+ };
654
+ }
655
+ if (!cfg.broker.appKey || !cfg.broker.appSecret) {
656
+ return { ok: false, error: "AppKey / SecretKey 미설정" };
657
+ }
658
+ const { getAccessToken, getDeposit, getHoldings, getPendingOrders, getExecutions, getOrderableQty } = await import("./broker/kiwoom/index.js");
659
+ const start = Date.now();
660
+ // 1. 토큰
661
+ const tokenRes = await getAccessToken({
662
+ appKey: cfg.broker.appKey,
663
+ secretKey: cfg.broker.appSecret,
664
+ isDemo: cfg.broker.isDemo,
665
+ });
666
+ const result = {
667
+ ok: tokenRes.ok,
668
+ env: cfg.broker.isDemo ? "demo" : "live",
669
+ checked_at: new Date().toISOString(),
670
+ auth: tokenRes.ok
671
+ ? {
672
+ ok: true,
673
+ token_preview: tokenRes.data.token.slice(0, 12) + "...",
674
+ expires_at: tokenRes.data.expiresAt,
675
+ cached: !!tokenRes.meta?.cached,
676
+ }
677
+ : { ok: false, error: tokenRes.error },
678
+ };
679
+ if (!tokenRes.ok) {
680
+ result.elapsed_ms = Date.now() - start;
681
+ return result;
682
+ }
683
+ // 2. 병렬 호출 (rate-limiter 가 직렬화)
684
+ const [depositRes, holdingsRes, pendingRes, execsRes] = await Promise.all([
685
+ getDeposit(),
686
+ getHoldings(),
687
+ getPendingOrders(q.symbolCode),
688
+ getExecutions(),
689
+ ]);
690
+ result.deposit = depositRes.ok
691
+ ? { ok: true, ...depositRes.data, raw_keys: Object.keys(depositRes.data.raw).slice(0, 10) }
692
+ : { ok: false, error: depositRes.error };
693
+ result.holdings = holdingsRes.ok
694
+ ? {
695
+ ok: true,
696
+ count: holdingsRes.data.items.length,
697
+ totalEvalKrw: holdingsRes.data.totalEvalKrw,
698
+ totalPnlKrw: holdingsRes.data.totalPnlKrw,
699
+ totalPnlPct: holdingsRes.data.totalPnlPct,
700
+ items: holdingsRes.data.items.slice(0, 20),
701
+ }
702
+ : { ok: false, error: holdingsRes.error };
703
+ result.pendingOrders = pendingRes.ok
704
+ ? { ok: true, count: pendingRes.data.length, items: pendingRes.data.slice(0, 20) }
705
+ : { ok: false, error: pendingRes.error };
706
+ result.executionsToday = execsRes.ok
707
+ ? { ok: true, count: execsRes.data.length, items: execsRes.data.slice(0, 20) }
708
+ : { ok: false, error: execsRes.error };
709
+ // 3. 종목 지정 시 주문가능수량
710
+ if (q.symbolCode) {
711
+ const oqRes = await getOrderableQty(q.symbolCode);
712
+ result.orderableQty = oqRes.ok ? { ok: true, ...oqRes.data } : { ok: false, error: oqRes.error };
713
+ }
714
+ result.elapsed_ms = Date.now() - start;
715
+ return result;
716
+ });
717
+ // ── Trade Journal (영구 매매 히스토리) ─────────────────
718
+ // GET /api/journal/events ?from=&to=&persona=&symbol=&type=&search=
719
+ // GET /api/journal/stats
720
+ // GET /api/journal/files
721
+ // GET /api/journal/orders · OrderTracker 활성/이력
722
+ // GET /api/journal/telemetry · 송신 큐 상태
723
+ app.get("/api/journal/events", async (req) => {
724
+ const q = req.query;
725
+ const events = readEvents({
726
+ from: q.from,
727
+ to: q.to,
728
+ personaSlug: q.persona,
729
+ symbolCode: q.symbol,
730
+ eventTypes: q.type ? [q.type] : undefined,
731
+ textSearch: q.search,
732
+ limit: q.limit ? parseInt(q.limit, 10) : 200,
733
+ });
734
+ return { count: events.length, events };
735
+ });
736
+ // GET /api/quote/current/:code · 현재가 (agent 도메인 도구)
737
+ app.get("/api/quote/current/:code", async (req, reply) => {
738
+ const { code } = req.params;
739
+ const cfg = loadConfig();
740
+ if (cfg.broker.kind !== "kiwoom" || !cfg.broker.appKey || !cfg.broker.appSecret) {
741
+ reply.code(200);
742
+ return { ok: false, error: "broker:not_configured", code };
743
+ }
744
+ try {
745
+ const { getCurrentPrice } = await import("./broker/kiwoom/index.js");
746
+ const res = await getCurrentPrice(code);
747
+ if (!res.ok) {
748
+ reply.code(200);
749
+ return { ok: false, error: res.error ?? "kiwoom:fetch_failed", code };
750
+ }
751
+ return { ok: true, code, ...res.data };
752
+ }
753
+ catch (e) {
754
+ reply.code(200);
755
+ return { ok: false, error: String(e), code };
756
+ }
757
+ });
758
+ // GET /api/quote/daily/:code?days=30
759
+ // - 키움 ka10081 일봉 (수정주가) · 60s 캐시 활용
760
+ // - 트레이더 dialog 의 캔들차트 표시용
761
+ // - kiwoom 미설정 시 ok:false (UI 가 fallback)
762
+ app.get("/api/quote/daily/:code", async (req, reply) => {
763
+ const { code } = req.params;
764
+ const q = req.query;
765
+ const days = q.days ? Math.max(5, Math.min(120, parseInt(q.days, 10))) : 30;
766
+ const cfg = loadConfig();
767
+ if (cfg.broker.kind !== "kiwoom" || !cfg.broker.appKey || !cfg.broker.appSecret) {
768
+ reply.code(200);
769
+ return { ok: false, error: "broker:not_configured", code, days, candles: [] };
770
+ }
771
+ try {
772
+ const { getDailyChart } = await import("./broker/kiwoom/index.js");
773
+ const res = await getDailyChart(code, days);
774
+ if (!res.ok) {
775
+ reply.code(200);
776
+ return { ok: false, error: res.error ?? "kiwoom:fetch_failed", code, days, candles: [] };
777
+ }
778
+ // 시간 오름차순 (오래 → 최신) 으로 정렬해서 차트 그리기 쉽게
779
+ const candles = [...res.data.candles].sort((a, b) => a.date.localeCompare(b.date));
780
+ return { ok: true, code, days, candles };
781
+ }
782
+ catch (e) {
783
+ reply.code(200);
784
+ return { ok: false, error: String(e), code, days, candles: [] };
785
+ }
786
+ });
787
+ app.get("/api/journal/stats", async (req) => {
788
+ const q = req.query;
789
+ return computeStats({
790
+ from: q.from,
791
+ to: q.to,
792
+ personaSlug: q.persona,
793
+ symbolCode: q.symbol,
794
+ });
795
+ });
796
+ app.get("/api/journal/files", async () => {
797
+ return { files: listJournalFiles() };
798
+ });
799
+ app.get("/api/journal/orders", async (req) => {
800
+ const q = req.query;
801
+ const tracker = getOrderTracker();
802
+ return {
803
+ stats: tracker.stats(),
804
+ orders: tracker.list({
805
+ symbolCode: q.symbolCode,
806
+ activeOnly: q.activeOnly === "true",
807
+ }),
808
+ };
809
+ });
810
+ app.get("/api/journal/telemetry", async () => {
811
+ return getTelemetry().stats();
812
+ });
813
+ // 일별 손익 + 누적 손익 (FIFO 추정)
814
+ app.get("/api/journal/pnl", async (req) => {
815
+ const q = req.query;
816
+ const { computeDailyPnl } = await import("./journal/pnl-stats.js");
817
+ return computeDailyPnl({
818
+ from: q.from,
819
+ to: q.to,
820
+ personaSlug: q.persona,
821
+ });
822
+ });
823
+ // Backtest replay (과거 decisions 재생)
824
+ app.get("/api/journal/replay", async (req) => {
825
+ const q = req.query;
826
+ const { replayJournal } = await import("./journal/pnl-stats.js");
827
+ return replayJournal({
828
+ from: q.from,
829
+ to: q.to,
830
+ personaSlug: q.persona,
831
+ });
832
+ });
833
+ app.delete("/api/watchers/:id", async (req, reply) => {
834
+ const { id } = req.params;
835
+ const cw = getConditionWatcher();
836
+ const removed = cw.remove(id, "revoked");
837
+ if (!removed) {
838
+ reply.code(404);
839
+ return { ok: false, error: "watcher not found" };
840
+ }
841
+ return { ok: true, id };
842
+ });
843
+ /**
844
+ * SPEC-27 cleanup 도구 — bulk delete (agent 가 중복 정리용).
845
+ *
846
+ * DELETE /api/watchers?personaSlug=neoul&symbolCode=014910
847
+ * - personaSlug 만: 그 페르소나의 모든 활성 watcher 제거
848
+ * - personaSlug + symbolCode: 그 종목의 활성 watcher 만 제거
849
+ * - keepIds=wt_xxx,wt_yyy: 이 id 들은 유지 (가장 최근 1개만 살리기 패턴)
850
+ */
851
+ app.delete("/api/watchers", async (req, reply) => {
852
+ const q = req.query;
853
+ if (!q.personaSlug) {
854
+ reply.code(400);
855
+ return { ok: false, error: "personaSlug 필수" };
856
+ }
857
+ const cw = getConditionWatcher();
858
+ const keep = new Set((q.keepIds ?? "").split(",").map((s) => s.trim()).filter(Boolean));
859
+ const list = cw.list({ personaSlug: q.personaSlug, activeOnly: true });
860
+ const removed = [];
861
+ for (const w of list) {
862
+ if (keep.has(w.id))
863
+ continue;
864
+ if (q.symbolCode) {
865
+ const cfg = w.spec.condition?.config;
866
+ if (cfg?.symbolCode !== q.symbolCode)
867
+ continue;
868
+ }
869
+ if (cw.remove(w.id, "revoked"))
870
+ removed.push(w.id);
871
+ }
872
+ return { ok: true, removed, removedCount: removed.length };
873
+ });
874
+ // ── Complete · 조립된 prompt 로 실제 LLM 호출 ────────────────
875
+ // Readiness 와 중복되는 LLM 단독 · Bundle 단독 테스트는 제거 · Complete 만 유지.
876
+ // device token 은 config 에서 자동 사용 (persona Bearer 인증).
877
+ const CompleteSchema = z.object({
878
+ personaSlug: z.string().optional(),
879
+ /** 명시적 layer3Header (없으면 buildLayer3Header 자동 조합) */
880
+ layer3Header: z.string().optional(),
881
+ /** 자동 Layer 3 조합 비활성 (디버깅 시) */
882
+ skipAutoLayer3: z.boolean().optional(),
883
+ userQuestion: z.string().default("오늘 무엇을 할지 판단해줘."),
884
+ maxTokens: z.number().int().positive().optional(),
885
+ });
886
+ app.post("/api/test/complete", async (req, reply) => {
887
+ const parsed = CompleteSchema.safeParse(req.body ?? {});
888
+ if (!parsed.success) {
889
+ reply.code(400);
890
+ return { ok: false, error: "스키마 오류", issues: parsed.error.issues };
891
+ }
892
+ const cfg = loadConfig();
893
+ const prov = createLlmProvider(cfg);
894
+ if (!prov) {
895
+ reply.code(400);
896
+ return { ok: false, error: "provider=none · 설정 페이지에서 먼저 선택" };
897
+ }
898
+ try {
899
+ // 1) common 로드
900
+ const common = await fetchCommonBundle(SERVER_BASE_URL);
901
+ // 2) persona 선택 시 함께 조립 · device token 으로 Bearer 인증
902
+ let personaBody = "";
903
+ if (parsed.data.personaSlug) {
904
+ const persona = await fetchPersonaBundle(SERVER_BASE_URL, parsed.data.personaSlug, cfg.auth?.deviceToken);
905
+ personaBody = persona.bundle.body;
906
+ }
907
+ // Layer 3 자동 조합 (보유/관심/lessons/지수)
908
+ let layer3Header = parsed.data.layer3Header;
909
+ if (!layer3Header && !parsed.data.skipAutoLayer3 && parsed.data.personaSlug) {
910
+ try {
911
+ layer3Header = await buildLayer3Header({ personaSlug: parsed.data.personaSlug });
912
+ }
913
+ catch (e) {
914
+ console.warn("[/api/test/complete] buildLayer3Header 실패 · 무시:", e);
915
+ }
916
+ }
917
+ // Layer 3 patches 자동 reload (Coach 가 출력한 markdown 파일들)
918
+ const layer3Patches = parsed.data.personaSlug
919
+ ? loadActivePatches(parsed.data.personaSlug)
920
+ : undefined;
921
+ const systemPrompt = assembleFullPrompt({
922
+ layer1: common.bundle.body,
923
+ layer2: personaBody || "# Layer 2 미선택 · 일반 Q&A",
924
+ layer3Header,
925
+ layer3Patches: layer3Patches || undefined,
926
+ });
927
+ const res = await prov.complete({
928
+ systemPrompt,
929
+ userPrompt: parsed.data.userQuestion,
930
+ maxTokens: parsed.data.maxTokens ?? 1024,
931
+ });
932
+ // Decision journal (LLM 응답 텍스트는 reasoning 으로 취급 · 구조화 파싱은 클라이언트 책임)
933
+ if (parsed.data.personaSlug) {
934
+ logDecision({
935
+ personaSlug: parsed.data.personaSlug,
936
+ llmContext: {
937
+ reasoning: res.text.slice(0, 1000),
938
+ tokens: res.usage,
939
+ model: res.model,
940
+ },
941
+ });
942
+ }
943
+ return {
944
+ ok: true,
945
+ result: res,
946
+ assembled_length: systemPrompt.length,
947
+ layer3_auto: !!layer3Header && !parsed.data.layer3Header,
948
+ };
949
+ }
950
+ catch (e) {
951
+ reply.code(500);
952
+ return { ok: false, error: String(e) };
953
+ }
954
+ });
955
+ /* ════════════════════════════════════════════════════════════
956
+ * 키움 확장 API · 유니버스 · 지수 · 투자자 · 테마 · 계좌 심화
957
+ * ════════════════════════════════════════════════════════════ */
958
+ // ── 유니버스 (ka10099) ──────────────────────────────────────
959
+ app.get("/api/universe", async (req) => {
960
+ const q = req.query;
961
+ const market = (q.market ?? "kospi");
962
+ const r = await getStockUniverse(market);
963
+ return r;
964
+ });
965
+ app.get("/api/universe/standard", async () => {
966
+ return await getStandardUniverse();
967
+ });
968
+ // ── 시장 지수 (ka20003) ─────────────────────────────────────
969
+ app.get("/api/index/:market", async (req) => {
970
+ const { market } = req.params;
971
+ return await getMarketIndex(market);
972
+ });
973
+ app.get("/api/sector/indices/:market", async (req) => {
974
+ const { market } = req.params;
975
+ return await getAllSectorIndices(market);
976
+ });
977
+ app.get("/api/sector/codes", async (req) => {
978
+ const q = req.query;
979
+ return await getSectorCodes(q.market ?? "kospi");
980
+ });
981
+ // ── 투자자 동향 (ka10008/09/131) ────────────────────────────
982
+ app.get("/api/investor/foreign/:code", async (req) => {
983
+ const { code } = req.params;
984
+ const q = req.query;
985
+ return await getForeignActivity(code, q.limit ? Number(q.limit) : 20);
986
+ });
987
+ app.get("/api/investor/institution/:code", async (req) => {
988
+ const { code } = req.params;
989
+ return await getInstitutionActivity(code);
990
+ });
991
+ app.get("/api/investor/continuous", async (req) => {
992
+ const q = req.query;
993
+ return await getContinuousInvestorActivity({
994
+ period: q.period,
995
+ market: q.market,
996
+ amountOrQty: q.amountOrQty,
997
+ startDate: q.startDate,
998
+ endDate: q.endDate,
999
+ });
1000
+ });
1001
+ // ── 랭킹 확장 (ka10023 거래량급증 · ka10031 전일거래량상위) ─
1002
+ app.get("/api/ranking/volume-surge", async (req) => {
1003
+ const q = req.query;
1004
+ return await getVolumeSurgeRanking({
1005
+ market: q.market,
1006
+ sort: q.sort,
1007
+ timeBasis: q.timeBasis,
1008
+ minuteWindow: q.minuteWindow,
1009
+ minVolume: q.minVolume,
1010
+ stockCondition: q.stockCondition,
1011
+ priceCondition: q.priceCondition,
1012
+ limit: q.limit ? Number(q.limit) : undefined,
1013
+ });
1014
+ });
1015
+ app.get("/api/ranking/yesterday-volume", async (req) => {
1016
+ const q = req.query;
1017
+ return await getYesterdayVolumeRanking({
1018
+ market: q.market,
1019
+ kind: q.kind,
1020
+ rankStart: q.rankStart ? Number(q.rankStart) : undefined,
1021
+ rankEnd: q.rankEnd ? Number(q.rankEnd) : undefined,
1022
+ });
1023
+ });
1024
+ // ── 일별거래상세 / 일별주가 (ka10015/ka10086) ───────────────
1025
+ app.get("/api/detail/daily-trade/:code", async (req) => {
1026
+ const { code } = req.params;
1027
+ const q = req.query;
1028
+ return await getDailyTradeDetail(code, q.startDate);
1029
+ });
1030
+ app.get("/api/detail/daily-price/:code", async (req) => {
1031
+ const { code } = req.params;
1032
+ const q = req.query;
1033
+ return await getDailyStockPrice(code, q.queryDate, q.displayKind);
1034
+ });
1035
+ // ── 테마 (ka90001/ka90002) ──────────────────────────────────
1036
+ app.get("/api/themes", async (req) => {
1037
+ const q = req.query;
1038
+ return await listThemeGroups({
1039
+ queryKind: q.queryKind,
1040
+ symbolCode: q.symbolCode,
1041
+ daysAgo: q.daysAgo ? Number(q.daysAgo) : undefined,
1042
+ themeName: q.themeName,
1043
+ sort: q.sort,
1044
+ });
1045
+ });
1046
+ app.get("/api/themes/:groupCode", async (req) => {
1047
+ const { groupCode } = req.params;
1048
+ const q = req.query;
1049
+ return await getThemeMembers(groupCode, q.daysAgo ? Number(q.daysAgo) : 10);
1050
+ });
1051
+ // ── 계좌 심화 (kt00004/05/17 · ka10085 · ka10170) ──────────
1052
+ app.get("/api/account/assessment", async (req) => {
1053
+ const q = req.query;
1054
+ return await getAccountAssessment(q.excludeDelisted === "true");
1055
+ });
1056
+ app.get("/api/account/execution-balance", async () => {
1057
+ return await getExecutionBalance();
1058
+ });
1059
+ app.get("/api/account/daily-status", async () => {
1060
+ return await getAccountDailyStatus();
1061
+ });
1062
+ app.get("/api/account/profit-rate", async (req) => {
1063
+ const q = req.query;
1064
+ return await getAccountProfitRate(q.market ?? "unified");
1065
+ });
1066
+ app.get("/api/account/trade-diary", async (req) => {
1067
+ const q = req.query;
1068
+ return await getTradeDiary({
1069
+ baseDate: q.baseDate,
1070
+ kind: q.kind,
1071
+ cashCredit: q.cashCredit,
1072
+ });
1073
+ });
1074
+ /* ════════════════════════════════════════════════════════════
1075
+ * 로컬 유니버스 DB + 배치 + 스크리너
1076
+ * ════════════════════════════════════════════════════════════ */
1077
+ app.get("/api/local/universe/stats", async (req) => {
1078
+ const q = req.query;
1079
+ const count = countUniverse(q.market);
1080
+ const meta = getSyncMeta("universe");
1081
+ return { ok: true, count, meta };
1082
+ });
1083
+ app.get("/api/local/universe", async (req) => {
1084
+ const q = req.query;
1085
+ const rows = listUniverse({ market: q.market, limit: q.limit ? Number(q.limit) : 50 });
1086
+ return { ok: true, count: rows.length, items: rows };
1087
+ });
1088
+ /**
1089
+ * Watchers 테이블 vacuum (P1-4 보강 · 수동 호출).
1090
+ *
1091
+ * - 기본 30일+ 된 non-active row (status='triggered'/'expired'/'revoked') 자동 정리
1092
+ * - status='active' row 는 절대 안 건드림 (운영 중 안전)
1093
+ * - boot 시 자동 1회 호출되지만, 사용자가 수동 호출 가능 (대시보드 「누적 정리」 버튼 등)
1094
+ *
1095
+ * Body: { days?: number } (기본 30 · 1 이상)
1096
+ */
1097
+ app.post("/api/local/vacuum", async (req, reply) => {
1098
+ const body = (req.body ?? {});
1099
+ const days = body.days ?? 30;
1100
+ if (!Number.isFinite(days) || days < 1) {
1101
+ reply.code(400);
1102
+ return { ok: false, error: "days 는 1 이상 정수" };
1103
+ }
1104
+ try {
1105
+ const before = watchersTableStats();
1106
+ const removed = vacuumWatchersOlderThan(days);
1107
+ const after = watchersTableStats();
1108
+ return {
1109
+ ok: true,
1110
+ days,
1111
+ removed,
1112
+ before,
1113
+ after,
1114
+ };
1115
+ }
1116
+ catch (e) {
1117
+ reply.code(500);
1118
+ return { ok: false, error: String(e) };
1119
+ }
1120
+ });
1121
+ app.post("/api/local/sync", async (req, reply) => {
1122
+ const body = (req.body ?? {});
1123
+ try {
1124
+ const r = await runUniverseSync({
1125
+ maxCodes: body.maxCodes ?? 50,
1126
+ candleDays: body.candleDays ?? 120,
1127
+ targetCodes: body.targetCodes,
1128
+ });
1129
+ return { ok: true, result: r };
1130
+ }
1131
+ catch (e) {
1132
+ reply.code(500);
1133
+ return { ok: false, error: String(e) };
1134
+ }
1135
+ });
1136
+ /**
1137
+ * 너울 양음양 타겟 배치:
1138
+ * ka10031 전일 거래량 상위 (KOSPI + KOSDAQ) + 현재 보유 종목 → 일봉 배치.
1139
+ * D-1 장대양봉 + 대량거래 후보 중심의 경량 배치. 1~2 분 소요.
1140
+ */
1141
+ app.post("/api/local/sync/yinyang", async (req, reply) => {
1142
+ const body = (req.body ?? {});
1143
+ const candleDays = body.candleDays ?? 120;
1144
+ try {
1145
+ const [kospiVol, kosdaqVol, holdingsRes] = await Promise.all([
1146
+ import("./broker/kiwoom/index.js").then((m) => m.getYesterdayVolumeRanking({ market: "kospi", kind: "volume", rankStart: 0, rankEnd: 100 })),
1147
+ import("./broker/kiwoom/index.js").then((m) => m.getYesterdayVolumeRanking({ market: "kosdaq", kind: "volume", rankStart: 0, rankEnd: 100 })),
1148
+ import("./broker/kiwoom/index.js").then((m) => m.getHoldings()),
1149
+ ]);
1150
+ const codes = new Set();
1151
+ if (kospiVol.ok)
1152
+ for (const r of kospiVol.data)
1153
+ codes.add(r.symbolCode);
1154
+ if (kosdaqVol.ok)
1155
+ for (const r of kosdaqVol.data)
1156
+ codes.add(r.symbolCode);
1157
+ if (holdingsRes.ok)
1158
+ for (const h of holdingsRes.data.items)
1159
+ codes.add(h.symbolCode.replace(/^A/, ""));
1160
+ const r = await runUniverseSync({
1161
+ candleDays,
1162
+ targetCodes: Array.from(codes),
1163
+ });
1164
+ return {
1165
+ ok: true,
1166
+ targetCodes: Array.from(codes),
1167
+ targetCount: codes.size,
1168
+ result: r,
1169
+ };
1170
+ }
1171
+ catch (e) {
1172
+ reply.code(500);
1173
+ return { ok: false, error: String(e) };
1174
+ }
1175
+ });
1176
+ app.post("/api/local/screener", async (req, reply) => {
1177
+ const body = (req.body ?? {});
1178
+ try {
1179
+ const r = await runScreener(body);
1180
+ return { ok: true, result: r };
1181
+ }
1182
+ catch (e) {
1183
+ reply.code(500);
1184
+ return { ok: false, error: String(e) };
1185
+ }
1186
+ });
1187
+ /* ════════════════════════════════════════════════════════════
1188
+ * WebSocket · 조건검색 (선택 기능 · 파워유저용)
1189
+ * ════════════════════════════════════════════════════════════ */
1190
+ app.get("/api/ws/status", async () => {
1191
+ const ws = getKiwoomWs();
1192
+ return { ok: true, status: ws.getStatus(), ready: ws.isReady() };
1193
+ });
1194
+ app.post("/api/ws/connect", async (req, reply) => {
1195
+ const ws = getKiwoomWs();
1196
+ const r = await ws.connect();
1197
+ if (!r.ok) {
1198
+ reply.code(500);
1199
+ return { ok: false, error: r.error };
1200
+ }
1201
+ return { ok: true, status: ws.getStatus() };
1202
+ });
1203
+ app.post("/api/ws/disconnect", async () => {
1204
+ const ws = getKiwoomWs();
1205
+ await ws.disconnect();
1206
+ return { ok: true };
1207
+ });
1208
+ app.get("/api/ws/conditions", async () => {
1209
+ return await listConditions();
1210
+ });
1211
+ app.get("/api/ws/conditions/:seq/run", async (req) => {
1212
+ const { seq } = req.params;
1213
+ return await requestCondition({ seq });
1214
+ });
1215
+ }
1216
+ //# sourceMappingURL=routes.js.map