@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.
- package/CHANGELOG.md +63 -0
- package/README.md +42 -5
- package/bin/cli.js +618 -38
- package/dist/server/auth/admin.js +32 -0
- package/dist/server/auth/admin.js.map +1 -0
- package/dist/server/broker/kiwoom/_helpers/symbol-code.js +21 -0
- package/dist/server/broker/kiwoom/_helpers/symbol-code.js.map +1 -0
- package/dist/server/broker/kiwoom/client.js +23 -4
- package/dist/server/broker/kiwoom/client.js.map +1 -1
- package/dist/server/broker/kiwoom/endpoints/account.js +14 -6
- package/dist/server/broker/kiwoom/endpoints/account.js.map +1 -1
- package/dist/server/broker/kiwoom/endpoints/quote.js +99 -13
- package/dist/server/broker/kiwoom/endpoints/quote.js.map +1 -1
- package/dist/server/broker/kiwoom/index.js +1 -1
- package/dist/server/broker/kiwoom/index.js.map +1 -1
- package/dist/server/broker/kiwoom/order-tracker.js +13 -0
- package/dist/server/broker/kiwoom/order-tracker.js.map +1 -1
- package/dist/server/broker/kiwoom/price-feed.js +13 -0
- package/dist/server/broker/kiwoom/price-feed.js.map +1 -1
- package/dist/server/broker/kiwoom/ws/endpoints/condition.js +3 -2
- package/dist/server/broker/kiwoom/ws/endpoints/condition.js.map +1 -1
- package/dist/server/calendar/kst-marker.js +68 -0
- package/dist/server/calendar/kst-marker.js.map +1 -0
- package/dist/server/config.js +370 -72
- package/dist/server/config.js.map +1 -1
- package/dist/server/db/sqlite.js +53 -29
- package/dist/server/db/sqlite.js.map +1 -1
- package/dist/server/diary/writer.js +151 -46
- package/dist/server/diary/writer.js.map +1 -1
- package/dist/server/index.js +166 -11
- package/dist/server/index.js.map +1 -1
- package/dist/server/jobs/universe-sync.js +48 -11
- package/dist/server/jobs/universe-sync.js.map +1 -1
- package/dist/server/jobs/watchdog.js +4 -0
- package/dist/server/jobs/watchdog.js.map +1 -1
- package/dist/server/journal/telemetry.js +11 -0
- package/dist/server/journal/telemetry.js.map +1 -1
- package/dist/server/journal/trade-journal.js +42 -16
- package/dist/server/journal/trade-journal.js.map +1 -1
- package/dist/server/llm/context-builder.js +9 -5
- package/dist/server/llm/context-builder.js.map +1 -1
- package/dist/server/personas/loader.js +46 -16
- package/dist/server/personas/loader.js.map +1 -1
- package/dist/server/personas/persona-agent.js +93 -21
- package/dist/server/personas/persona-agent.js.map +1 -1
- package/dist/server/personas/persona-state.js +198 -19
- package/dist/server/personas/persona-state.js.map +1 -1
- package/dist/server/personas/runner.js +29 -0
- package/dist/server/personas/runner.js.map +1 -1
- package/dist/server/personas/wake-plan.js +4 -67
- package/dist/server/personas/wake-plan.js.map +1 -1
- package/dist/server/readiness.js +70 -0
- package/dist/server/readiness.js.map +1 -1
- package/dist/server/routes.js +276 -12
- package/dist/server/routes.js.map +1 -1
- package/dist/server/screener/engine.js +8 -1
- package/dist/server/screener/engine.js.map +1 -1
- package/dist/server/screener/indicators.js +63 -0
- package/dist/server/screener/indicators.js.map +1 -0
- package/dist/server/server-sync.js +365 -125
- package/dist/server/server-sync.js.map +1 -1
- package/dist/server/share/postgres-publish.js +139 -0
- package/dist/server/share/postgres-publish.js.map +1 -0
- package/dist/server/share/prepare-state.js +50 -0
- package/dist/server/share/prepare-state.js.map +1 -0
- package/dist/server/share/prepare.js +61 -0
- package/dist/server/share/prepare.js.map +1 -0
- package/dist/server/share/publish-cron.js +96 -0
- package/dist/server/share/publish-cron.js.map +1 -0
- package/dist/server/share/snapshot.js +81 -0
- package/dist/server/share/snapshot.js.map +1 -0
- package/dist/server/watchers/condition-watcher.js +57 -16
- package/dist/server/watchers/condition-watcher.js.map +1 -1
- package/dist/web/assets/index-B1C-UX9W.js +88 -0
- package/dist/web/assets/index-N7Xwheka.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +4 -5
- package/dist/web/assets/index-62SMpbaf.js +0 -79
- package/dist/web/assets/index-BPLQR0wt.css +0 -1
package/bin/cli.js
CHANGED
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* mulsok-client CLI
|
|
3
|
+
* mulsok-client CLI · headless / SSH 환경 지원
|
|
4
4
|
*
|
|
5
5
|
* 사용:
|
|
6
|
-
* mulsok-client install
|
|
7
|
-
* mulsok-client
|
|
8
|
-
* mulsok-client
|
|
9
|
-
* mulsok-client
|
|
10
|
-
* mulsok-client
|
|
11
|
-
* mulsok-client
|
|
6
|
+
* mulsok-client install / uninstall / update · 데몬 lifecycle
|
|
7
|
+
* mulsok-client status · daemon 상태
|
|
8
|
+
* mulsok-client logs · 로그 tail -f
|
|
9
|
+
* mulsok-client config show · 현재 설정 (마스킹)
|
|
10
|
+
* mulsok-client config token [<TOKEN>] · 디바이스 인증
|
|
11
|
+
* mulsok-client config llm <provider> [--api-key=K]
|
|
12
|
+
* [--model=M] · LLM provider 설정
|
|
13
|
+
* mulsok-client config broker kiwoom --app-key=K
|
|
14
|
+
* --app-secret=S [--demo|--live] · 키움 등록
|
|
15
|
+
* mulsok-client config subs list|add|remove [<slug>] · 구독 관리
|
|
16
|
+
* mulsok-client help · 이 메시지
|
|
17
|
+
*
|
|
18
|
+
* 모든 config 명령은 daemon HTTP API (127.0.0.1:5903) thin wrapper.
|
|
19
|
+
* webui 와 동일 데이터 흐름 — 단일 진실.
|
|
12
20
|
*/
|
|
13
21
|
import { execSync, spawnSync } from "node:child_process";
|
|
14
22
|
import path from "node:path";
|
|
23
|
+
import readline from "node:readline";
|
|
15
24
|
import { fileURLToPath } from "node:url";
|
|
16
25
|
import { existsSync } from "node:fs";
|
|
17
26
|
|
|
@@ -47,15 +56,39 @@ function runScript(scriptName) {
|
|
|
47
56
|
}
|
|
48
57
|
|
|
49
58
|
function help() {
|
|
50
|
-
console.log(`${bold("mulsok-client")} · mulsok-traders 데스크톱 클라이언트
|
|
59
|
+
console.log(`${bold("mulsok-client")} · mulsok-traders 데스크톱 클라이언트 (headless / SSH 지원)
|
|
60
|
+
|
|
61
|
+
${bold("데몬 lifecycle")}
|
|
62
|
+
install 데몬 등록 + 가동 (npm install 시 자동)
|
|
63
|
+
uninstall 데몬 제거 (데이터 보존)
|
|
64
|
+
update npm update + 데몬 재시작
|
|
65
|
+
status [--json] daemon · 인증 · 설정 · 계좌 · 감시자 · readiness
|
|
66
|
+
logs 로그 실시간 tail
|
|
67
|
+
|
|
68
|
+
${bold("config — daemon API thin wrapper (webui 없이 운영)")}
|
|
69
|
+
config show 현재 설정 (마스킹 출력)
|
|
70
|
+
config token [<TOKEN>] 디바이스 인증 (인자 없으면 stdin 비밀 입력)
|
|
71
|
+
config llm <provider> LLM 설정 [--api-key=K] [--model=M]
|
|
72
|
+
providers: claude-cli·anthropic·openai·gemini
|
|
73
|
+
config broker kiwoom 키움 등록
|
|
74
|
+
--app-key=K --app-secret=S [--demo|--live]
|
|
75
|
+
config subs list 구독 목록
|
|
76
|
+
config subs add <slug> 구독 추가
|
|
77
|
+
config subs remove <slug> 구독 제거
|
|
51
78
|
|
|
52
|
-
${bold("명령")}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
79
|
+
${bold("운영 명령")}
|
|
80
|
+
prepare 시세 데이터 download (첫 install 5~10분 · progress 표시)
|
|
81
|
+
safety status 현재 safety 상태
|
|
82
|
+
safety halt [reason] 긴급 정지
|
|
83
|
+
safety resume 정상 가동 재개
|
|
84
|
+
cycle <slug> 수동 cycle (7~13분 · 분석 후 요약)
|
|
85
|
+
diary <slug> [--date=] 일기 본문 (markdown ANSI · default 오늘)
|
|
86
|
+
diary <slug> --list 일기 목록 (날짜 + 길이 + 발췌)
|
|
87
|
+
journal [--days=7] 최근 이벤트 timeline [--tail=10]
|
|
88
|
+
watchers [--persona=slug] active 감시자 표
|
|
89
|
+
share dump (admin) 시세 SQLite VACUUM INTO + gzip dump
|
|
90
|
+
share publish-postgres (admin) SQLite → Supabase Postgres bulk upsert
|
|
91
|
+
[--universe-only|--candles-only]
|
|
59
92
|
|
|
60
93
|
${bold("운영 위치")}
|
|
61
94
|
설정/데이터: ~/.mulsok-traders/
|
|
@@ -67,34 +100,126 @@ function help() {
|
|
|
67
100
|
`);
|
|
68
101
|
}
|
|
69
102
|
|
|
70
|
-
function status() {
|
|
71
|
-
|
|
72
|
-
|
|
103
|
+
async function status(asJson = false) {
|
|
104
|
+
// 데이터 수집 — daemon 응답 없으면 부분 결과만
|
|
105
|
+
let daemonState = null;
|
|
106
|
+
if (isMac()) {
|
|
107
|
+
try {
|
|
108
|
+
const out = execSync(`launchctl list | grep ${LABEL} || true`).toString().trim();
|
|
109
|
+
daemonState = out || null;
|
|
110
|
+
} catch { /* ignore */ }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let health = null;
|
|
114
|
+
let identity = null;
|
|
115
|
+
let cfg = null;
|
|
116
|
+
let watchers = null;
|
|
117
|
+
let account = null;
|
|
118
|
+
let readiness = null;
|
|
119
|
+
try { health = await (await fetch(`${API_BASE}/api/health`)).json(); } catch { /* ignore */ }
|
|
120
|
+
if (health) {
|
|
121
|
+
try { identity = await (await fetch(`${API_BASE}/api/me/identity`)).json(); } catch { /* ignore */ }
|
|
122
|
+
try { cfg = await (await fetch(`${API_BASE}/api/config`)).json(); } catch { /* ignore */ }
|
|
123
|
+
try { watchers = await (await fetch(`${API_BASE}/api/watchers`)).json(); } catch { /* ignore */ }
|
|
124
|
+
try { account = await (await fetch(`${API_BASE}/api/account/status`)).json(); } catch { /* ignore */ }
|
|
125
|
+
try { readiness = await (await fetch(`${API_BASE}/api/readiness`)).json(); } catch { /* ignore */ }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (asJson) {
|
|
129
|
+
console.log(JSON.stringify({
|
|
130
|
+
daemon: { running: !!daemonState, line: daemonState },
|
|
131
|
+
health, identity,
|
|
132
|
+
config: cfg?.config,
|
|
133
|
+
watchers: watchers?.stats,
|
|
134
|
+
account: account?.deposit && account?.holdings ? {
|
|
135
|
+
deposit: account.deposit,
|
|
136
|
+
holdings: { count: account.holdings.count, totalEval: account.holdings.totalEvalKrw, pnlPct: account.holdings.totalPnlPct },
|
|
137
|
+
} : null,
|
|
138
|
+
readiness: readiness ? { ready: readiness.ready, score: readiness.score } : null,
|
|
139
|
+
}, null, 2));
|
|
73
140
|
return;
|
|
74
141
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.log(`
|
|
142
|
+
|
|
143
|
+
// ── 사람 친화 출력 ──────────────────────────────────────
|
|
144
|
+
console.log(bold("▶ daemon"));
|
|
145
|
+
if (daemonState) {
|
|
146
|
+
const [pid, _exit, _label] = daemonState.split(/\s+/);
|
|
147
|
+
console.log(` ${green("● running")} · PID ${pid}`);
|
|
148
|
+
} else if (isMac()) {
|
|
149
|
+
console.log(` ${red("○ stopped")} · launchctl 등록 안 됨 (mulsok-client install 권장)`);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(` ${yellow("⚠ macOS 외 — launchctl 미지원")}`);
|
|
81
152
|
}
|
|
82
153
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
console.log(` ${r.slice(0, 200)}`);
|
|
88
|
-
} catch {
|
|
89
|
-
console.log(red(` ✗ ${HEALTH_URL} 응답 없음`));
|
|
154
|
+
if (!health) {
|
|
155
|
+
console.log(` ${red("✗ HTTP 응답 없음")} (${API_BASE}/api/health)`);
|
|
156
|
+
console.log(" → mulsok-client logs 로 stderr 확인 권장");
|
|
157
|
+
return;
|
|
90
158
|
}
|
|
159
|
+
console.log(` ${green(health.service)} v${health.version} · node ${health.node}`);
|
|
91
160
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
161
|
+
// 인증
|
|
162
|
+
console.log(`\n${bold("▶ 인증")}`);
|
|
163
|
+
if (identity?.userEmail) {
|
|
164
|
+
const adminBadge = identity.isAdmin ? ` ${yellow("[관리자]")}` : "";
|
|
165
|
+
console.log(` ${green("✓")} ${identity.userEmail}${adminBadge} (user ${identity.userId})`);
|
|
166
|
+
} else if (identity?.hasToken && !identity?.lastAuthenticated) {
|
|
167
|
+
console.log(` ${red("✗")} server 가 토큰 거부 — 새 토큰 발급 필요`);
|
|
168
|
+
} else if (identity?.hasToken) {
|
|
169
|
+
console.log(` ${yellow("⏳")} 인증 중 (server 응답 대기)`);
|
|
170
|
+
} else {
|
|
171
|
+
console.log(` ${yellow("○")} 인증되지 않음 — mulsok-client config token <T>`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// LLM / broker / safety 한 줄
|
|
175
|
+
const c = cfg?.config ?? {};
|
|
176
|
+
const llm = c.llm ?? {};
|
|
177
|
+
const broker = c.broker ?? {};
|
|
178
|
+
console.log(`\n${bold("▶ 설정")}`);
|
|
179
|
+
const llmStr = llm.provider
|
|
180
|
+
? `${llm.provider}${llm.claudeCli?.model ? ` (${llm.claudeCli.model})` : ""}`
|
|
181
|
+
: yellow("(미설정)");
|
|
182
|
+
const brokerStr = broker.kind
|
|
183
|
+
? `${broker.kind} · ${broker.isDemo ? yellow("모의") : red("실거래")}`
|
|
184
|
+
: yellow("(미설정)");
|
|
185
|
+
console.log(` ${pad("LLM", 10)} ${llmStr}`);
|
|
186
|
+
console.log(` ${pad("broker", 10)} ${brokerStr}`);
|
|
187
|
+
console.log(` ${pad("구독", 10)} ${(c.subscriptions ?? []).join(", ") || yellow("(없음)")}`);
|
|
188
|
+
console.log(` ${pad("safety", 10)} ${c.halted ? red("긴급 정지") : green("정상")}`);
|
|
189
|
+
|
|
190
|
+
// 계좌
|
|
191
|
+
if (account?.deposit?.ok) {
|
|
192
|
+
console.log(`\n${bold("▶ 계좌")}`);
|
|
193
|
+
const fmtKrw = (n) => n >= 10000 ? `${Math.round(n / 10_000).toLocaleString("ko-KR")}만원` : `${(n ?? 0).toLocaleString("ko-KR")}원`;
|
|
194
|
+
console.log(` ${pad("예수금", 10)} ${fmtKrw(account.deposit.totalDepositKrw ?? 0)}`);
|
|
195
|
+
console.log(` ${pad("가용현금", 10)} ${fmtKrw(account.deposit.availableKrw ?? 0)}`);
|
|
196
|
+
if (account.holdings?.count > 0) {
|
|
197
|
+
const pnl = account.holdings.totalPnlPct ?? 0;
|
|
198
|
+
const pnlColor = pnl > 0 ? green : pnl < 0 ? red : (s) => s;
|
|
199
|
+
console.log(` ${pad("보유종목", 10)} ${account.holdings.count}건 · 평가 ${fmtKrw(account.holdings.totalEvalKrw)} · ${pnlColor((pnl >= 0 ? "+" : "") + pnl.toFixed(2) + "%")}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// 감시자
|
|
204
|
+
if (watchers) {
|
|
205
|
+
console.log(`\n${bold("▶ 감시자")}`);
|
|
206
|
+
const s = watchers.stats ?? {};
|
|
207
|
+
console.log(` ${pad("active", 10)} ${s.active ?? 0}건`);
|
|
208
|
+
if (s.byPersona) {
|
|
209
|
+
const parts = Object.entries(s.byPersona).map(([k, v]) => `${k} ${v}`);
|
|
210
|
+
if (parts.length > 0) console.log(` ${pad("by persona", 10)} ${parts.join(" · ")}`);
|
|
211
|
+
}
|
|
212
|
+
const pf = watchers.priceFeed;
|
|
213
|
+
if (pf) console.log(` ${pad("priceFeed", 10)} ${pf.running ? green("running") : red("stopped")} · ${pf.stocks ?? 0} stocks`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// readiness
|
|
217
|
+
if (readiness?.score) {
|
|
218
|
+
console.log(`\n${bold("▶ readiness")} ${readiness.ready ? green("✓ ready") : yellow(`${readiness.score.ok}/${readiness.score.total_blocking}`)}`);
|
|
219
|
+
for (const item of readiness.items ?? []) {
|
|
220
|
+
const icon = item.status === "ok" ? green("✓") : item.status === "fail" ? red("✗") : yellow("○");
|
|
221
|
+
console.log(` ${icon} ${pad(item.label, 24)} ${item.detail ?? ""}`);
|
|
222
|
+
}
|
|
98
223
|
}
|
|
99
224
|
}
|
|
100
225
|
|
|
@@ -125,6 +250,437 @@ function update() {
|
|
|
125
250
|
}
|
|
126
251
|
}
|
|
127
252
|
|
|
253
|
+
/* ============================================================
|
|
254
|
+
config subcommands · daemon HTTP API thin wrapper (Phase 1)
|
|
255
|
+
============================================================ */
|
|
256
|
+
|
|
257
|
+
const API_BASE = "http://127.0.0.1:5903";
|
|
258
|
+
|
|
259
|
+
async function apiGet(p) {
|
|
260
|
+
try {
|
|
261
|
+
const r = await fetch(`${API_BASE}${p}`);
|
|
262
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
263
|
+
return await r.json();
|
|
264
|
+
} catch (e) {
|
|
265
|
+
console.error(red(`✗ daemon 응답 없음 (${p}) — 데몬이 가동 중인지 확인: mulsok-client status`));
|
|
266
|
+
console.error(red(` ${e.message}`));
|
|
267
|
+
process.exit(2);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function apiPost(p, body) {
|
|
272
|
+
try {
|
|
273
|
+
const r = await fetch(`${API_BASE}${p}`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
headers: { "content-type": "application/json" },
|
|
276
|
+
body: JSON.stringify(body ?? {}),
|
|
277
|
+
});
|
|
278
|
+
const j = await r.json().catch(() => ({}));
|
|
279
|
+
if (!r.ok) {
|
|
280
|
+
console.error(red(`✗ ${p} HTTP ${r.status}`));
|
|
281
|
+
if (j?.error) console.error(red(` ${j.error}`));
|
|
282
|
+
process.exit(2);
|
|
283
|
+
}
|
|
284
|
+
return j;
|
|
285
|
+
} catch (e) {
|
|
286
|
+
console.error(red(`✗ daemon 응답 없음 (${p})`));
|
|
287
|
+
console.error(red(` ${e.message}`));
|
|
288
|
+
process.exit(2);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** stdin 비밀 입력 (echo off 가까운 경험 — Node 표준 readline 한계로 마지막 줄만 가림) */
|
|
293
|
+
function readSecretLine(prompt) {
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
296
|
+
rl.question(prompt, (answer) => {
|
|
297
|
+
rl.close();
|
|
298
|
+
resolve(answer.trim());
|
|
299
|
+
});
|
|
300
|
+
// echo off — input 마다 line 위에 덮어쓰기 (제한적)
|
|
301
|
+
rl._writeToOutput = (s) => {
|
|
302
|
+
if (s.includes(prompt)) process.stdout.write(s);
|
|
303
|
+
else process.stdout.write("*");
|
|
304
|
+
};
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function maskSecret(v, keep = 4) {
|
|
309
|
+
if (typeof v !== "string" || v.length <= keep) return v ? "***" : "(없음)";
|
|
310
|
+
return `${v.slice(0, keep)}${"*".repeat(Math.max(4, v.length - keep))}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function pad(s, w) {
|
|
314
|
+
s = String(s);
|
|
315
|
+
return s + " ".repeat(Math.max(0, w - [...s].length));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function configShow(json) {
|
|
319
|
+
const cfg = json.config ?? {};
|
|
320
|
+
const auth = cfg.auth ?? {};
|
|
321
|
+
const llm = cfg.llm ?? {};
|
|
322
|
+
const broker = cfg.broker ?? {};
|
|
323
|
+
const subs = cfg.subscriptions ?? [];
|
|
324
|
+
|
|
325
|
+
const rows = [
|
|
326
|
+
["인증 user", auth.userEmail ? green(auth.userEmail) : yellow("(미인증)")],
|
|
327
|
+
["user_id", auth.userId ?? yellow("—")],
|
|
328
|
+
["device token", maskSecret(auth.deviceToken)],
|
|
329
|
+
["LLM provider", llm.provider ?? yellow("(미설정)")],
|
|
330
|
+
["LLM model", llm.claudeCli?.model ?? llm.anthropic?.model ?? llm.openai?.model ?? llm.gemini?.model ?? "—"],
|
|
331
|
+
["broker", broker.kind ?? yellow("(미설정)")],
|
|
332
|
+
["broker key", maskSecret(broker.appKey)],
|
|
333
|
+
["broker secret", maskSecret(broker.appSecret)],
|
|
334
|
+
["broker mode", broker.kind === "kiwoom" ? (broker.isDemo ? yellow("모의") : red("실거래")) : "—"],
|
|
335
|
+
["구독 수", String(subs.length)],
|
|
336
|
+
["구독 slugs", subs.length > 0 ? subs.join(", ") : yellow("(없음)")],
|
|
337
|
+
["safety", cfg.halted ? red("긴급 정지") : green("정상")],
|
|
338
|
+
];
|
|
339
|
+
console.log(bold("▶ 현재 설정 (마스킹)"));
|
|
340
|
+
for (const [k, v] of rows) console.log(` ${pad(k, 14)} ${v}`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function parseFlags(args) {
|
|
344
|
+
const flags = {};
|
|
345
|
+
const rest = [];
|
|
346
|
+
for (const a of args) {
|
|
347
|
+
if (a.startsWith("--")) {
|
|
348
|
+
const eq = a.indexOf("=");
|
|
349
|
+
if (eq > 0) flags[a.slice(2, eq)] = a.slice(eq + 1);
|
|
350
|
+
else flags[a.slice(2)] = true;
|
|
351
|
+
} else rest.push(a);
|
|
352
|
+
}
|
|
353
|
+
return { flags, rest };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function configToken(args) {
|
|
357
|
+
let token = args[0];
|
|
358
|
+
if (!token) {
|
|
359
|
+
token = await readSecretLine("디바이스 토큰 입력 (mtd_…) → ");
|
|
360
|
+
}
|
|
361
|
+
if (!token) {
|
|
362
|
+
console.error(red("✗ 토큰이 비어있습니다"));
|
|
363
|
+
process.exit(1);
|
|
364
|
+
}
|
|
365
|
+
if (!token.startsWith("mtd_")) {
|
|
366
|
+
console.error(yellow(`⚠ 토큰이 'mtd_' 로 시작하지 않습니다 (입력: ${maskSecret(token)})`));
|
|
367
|
+
}
|
|
368
|
+
console.log("토큰 등록 중 · server ping 대기...");
|
|
369
|
+
const r = await apiPost("/api/config", { auth: { deviceToken: token } });
|
|
370
|
+
void r;
|
|
371
|
+
// 인증 결과 확인
|
|
372
|
+
const id = await apiGet("/api/me/identity");
|
|
373
|
+
if (id.userEmail) {
|
|
374
|
+
console.log(green(`✓ 인증 성공: ${id.userEmail} (user ${id.userId})`));
|
|
375
|
+
} else if (id.hasToken && !id.lastAuthenticated) {
|
|
376
|
+
console.error(red("✗ server 가 토큰을 거부했습니다 — 새 토큰 발급 필요"));
|
|
377
|
+
process.exit(1);
|
|
378
|
+
} else {
|
|
379
|
+
console.log(yellow("⏳ 인증 진행 중 — `mulsok-client config show` 로 다시 확인"));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function configLlm(args) {
|
|
384
|
+
const { flags, rest } = parseFlags(args);
|
|
385
|
+
const provider = rest[0];
|
|
386
|
+
const valid = ["claude-cli", "anthropic", "openai", "gemini"];
|
|
387
|
+
if (!provider || !valid.includes(provider)) {
|
|
388
|
+
console.error(red(`✗ provider 지정: ${valid.join(" / ")}`));
|
|
389
|
+
process.exit(1);
|
|
390
|
+
}
|
|
391
|
+
const patch = { llm: { provider } };
|
|
392
|
+
// provider 별 config 키
|
|
393
|
+
if (provider === "claude-cli") {
|
|
394
|
+
patch.llm.claudeCli = { model: flags.model ?? "sonnet" };
|
|
395
|
+
if (flags["binary-path"]) patch.llm.claudeCli.binaryPath = flags["binary-path"];
|
|
396
|
+
} else {
|
|
397
|
+
patch.llm[provider] = {};
|
|
398
|
+
if (flags["api-key"]) patch.llm[provider].apiKey = flags["api-key"];
|
|
399
|
+
if (flags.model) patch.llm[provider].model = flags.model;
|
|
400
|
+
}
|
|
401
|
+
await apiPost("/api/config", patch);
|
|
402
|
+
console.log(green(`✓ LLM provider 설정: ${provider}${flags.model ? ` (${flags.model})` : ""}`));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function configBroker(args) {
|
|
406
|
+
const { flags, rest } = parseFlags(args);
|
|
407
|
+
const kind = rest[0];
|
|
408
|
+
if (kind !== "kiwoom") {
|
|
409
|
+
console.error(red("✗ 현재 지원: kiwoom 만 (KIS 추후)"));
|
|
410
|
+
process.exit(1);
|
|
411
|
+
}
|
|
412
|
+
const appKey = flags["app-key"];
|
|
413
|
+
const appSecret = flags["app-secret"];
|
|
414
|
+
if (!appKey || !appSecret) {
|
|
415
|
+
console.error(red("✗ --app-key=K --app-secret=S 필수"));
|
|
416
|
+
process.exit(1);
|
|
417
|
+
}
|
|
418
|
+
const isDemo = !flags.live; // default = demo · --live 면 실거래
|
|
419
|
+
const patch = { broker: { kind: "kiwoom", appKey, appSecret, isDemo } };
|
|
420
|
+
await apiPost("/api/config", patch);
|
|
421
|
+
console.log(green(`✓ 키움 등록 (${isDemo ? "모의투자" : red("⚠ 실거래")})`));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
async function configSubs(args) {
|
|
425
|
+
const sub = args[0];
|
|
426
|
+
if (sub === "list" || !sub) {
|
|
427
|
+
const r = await apiGet("/api/subscriptions");
|
|
428
|
+
const list = r.subscriptions ?? [];
|
|
429
|
+
console.log(bold(`▶ 구독 (${list.length}명)`));
|
|
430
|
+
if (list.length === 0) console.log(" (없음)");
|
|
431
|
+
else for (const s of list) console.log(` · ${s}`);
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
if (sub === "add" || sub === "remove") {
|
|
435
|
+
const slug = args[1];
|
|
436
|
+
if (!slug) {
|
|
437
|
+
console.error(red(`✗ slug 인자 필요: mulsok-client config subs ${sub} <slug>`));
|
|
438
|
+
process.exit(1);
|
|
439
|
+
}
|
|
440
|
+
const cur = await apiGet("/api/config");
|
|
441
|
+
const list = new Set(cur.config?.subscriptions ?? []);
|
|
442
|
+
if (sub === "add") list.add(slug);
|
|
443
|
+
else list.delete(slug);
|
|
444
|
+
await apiPost("/api/config", { subscriptions: [...list] });
|
|
445
|
+
console.log(green(`✓ ${sub === "add" ? "추가" : "제거"}: ${slug} · 총 ${list.size}명`));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
console.error(red(`✗ 알 수 없는 subs 명령: ${sub}`));
|
|
449
|
+
console.error(" 사용: list / add <slug> / remove <slug>");
|
|
450
|
+
process.exit(1);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* ============================================================
|
|
454
|
+
운영 명령 (Phase 3) — cycle · diary · journal · watchers · safety
|
|
455
|
+
============================================================ */
|
|
456
|
+
|
|
457
|
+
async function safetyCmd(args) {
|
|
458
|
+
const sub = args[0];
|
|
459
|
+
if (sub === "status" || !sub) {
|
|
460
|
+
const r = await apiGet("/api/safety");
|
|
461
|
+
console.log(`safety: ${r.halted ? red("긴급 정지") : green("정상 가동")}`);
|
|
462
|
+
if (r.reason) console.log(`reason: ${r.reason}`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (sub === "halt") {
|
|
466
|
+
const reason = args.slice(1).join(" ") || "manual cli";
|
|
467
|
+
await apiPost("/api/safety/halt", { reason });
|
|
468
|
+
console.log(red(`✗ 긴급 정지 (reason: ${reason})`));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
if (sub === "resume") {
|
|
472
|
+
await apiPost("/api/safety/resume");
|
|
473
|
+
console.log(green("✓ 정상 가동 재개"));
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
console.error(red(`✗ 알 수 없는 safety 명령: ${sub}`));
|
|
477
|
+
console.error(" 사용: status / halt [reason] / resume");
|
|
478
|
+
process.exit(1);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function cycleCmd(args) {
|
|
482
|
+
const slug = args[0];
|
|
483
|
+
if (!slug) {
|
|
484
|
+
console.error(red("✗ slug 인자 필요: mulsok-client cycle <slug>"));
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
console.log(`→ ${slug} cycle 시작 · 분석 시간 7~13분 (claude-cli sonnet)`);
|
|
488
|
+
const t0 = Date.now();
|
|
489
|
+
const r = await apiPost(`/api/personas/${slug}/cycle`, {});
|
|
490
|
+
const sec = Math.round((Date.now() - t0) / 1000);
|
|
491
|
+
if (!r.ok) {
|
|
492
|
+
console.error(red(`✗ cycle 실패 (${sec}s): ${r.error ?? "unknown"}`));
|
|
493
|
+
process.exit(1);
|
|
494
|
+
}
|
|
495
|
+
console.log(green(`✓ cycle 완료 (${sec}s)`));
|
|
496
|
+
if (r.summary) {
|
|
497
|
+
const lines = r.summary.replace(/<state_update>[\s\S]*?<\/state_update>/g, "").trim().split("\n");
|
|
498
|
+
const head = lines.slice(0, 12).join("\n");
|
|
499
|
+
console.log(`\n${bold("요약")}\n${head}`);
|
|
500
|
+
if (lines.length > 12) console.log(yellow(`\n (+${lines.length - 12} more · 풀 본문은 mulsok-client diary ${slug})`));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async function diaryCmd(args) {
|
|
505
|
+
const { flags, rest } = parseFlags(args);
|
|
506
|
+
const slug = rest[0];
|
|
507
|
+
if (!slug) {
|
|
508
|
+
console.error(red("✗ slug 인자 필요: mulsok-client diary <slug> [--date=YYYY-MM-DD]"));
|
|
509
|
+
process.exit(1);
|
|
510
|
+
}
|
|
511
|
+
if (flags.list) {
|
|
512
|
+
const r = await apiGet(`/api/diary/${slug}/list?limit=${flags.limit ?? 30}`);
|
|
513
|
+
console.log(bold(`▶ ${slug} 일기 (${(r.diaries ?? []).length}편)`));
|
|
514
|
+
for (const d of (r.diaries ?? [])) {
|
|
515
|
+
console.log(` ${pad(d.date, 12)} ${pad(`${d.bodyLength}자`, 8)} ${d.excerpt.replace(/^#[^\n]*\n+/, "").slice(0, 60).replace(/\n/g, " ")}…`);
|
|
516
|
+
}
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const date = flags.date ?? new Date(Date.now() + 9 * 3600_000).toISOString().slice(0, 10);
|
|
520
|
+
const r = await apiGet(`/api/diary/${slug}?date=${date}`);
|
|
521
|
+
if (!r.ok) {
|
|
522
|
+
console.error(red(`✗ ${date} 의 ${slug} 일기 없음 — mulsok-client diary ${slug} --list`));
|
|
523
|
+
process.exit(1);
|
|
524
|
+
}
|
|
525
|
+
// 가벼운 markdown → ANSI: # = bold, ** = bold, > = quote, --- = hr
|
|
526
|
+
const body = r.body
|
|
527
|
+
.replace(/^# (.+)$/gm, (_m, t) => `\n${bold(t)}\n`)
|
|
528
|
+
.replace(/^## (.+)$/gm, (_m, t) => `\n${bold(yellow(t))}\n`)
|
|
529
|
+
.replace(/^### (.+)$/gm, (_m, t) => `${bold(t)}`)
|
|
530
|
+
.replace(/\*\*(.+?)\*\*/g, (_m, t) => bold(t))
|
|
531
|
+
.replace(/^> (.+)$/gm, (_m, t) => ` ${yellow("│")} ${t}`)
|
|
532
|
+
.replace(/^---$/gm, "─".repeat(60));
|
|
533
|
+
console.log(body);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async function journalCmd(args) {
|
|
537
|
+
const { flags } = parseFlags(args);
|
|
538
|
+
const days = flags.days ?? 7;
|
|
539
|
+
const limit = parseInt(flags.tail ?? "10", 10);
|
|
540
|
+
const r = await apiGet(`/api/journal/events?days=${days}`);
|
|
541
|
+
const events = (r.events ?? []).slice(-limit).reverse();
|
|
542
|
+
console.log(bold(`▶ 최근 ${events.length}건 이벤트 (최근 ${days}일)`));
|
|
543
|
+
for (const e of events) {
|
|
544
|
+
const ts = new Date(e.timestamp).toLocaleString("ko-KR", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
545
|
+
const type = pad(e.eventType ?? "?", 18);
|
|
546
|
+
const persona = pad(e.personaSlug ?? "—", 8);
|
|
547
|
+
const meta = e.symbolName || (e.meta && e.meta.intent) || "";
|
|
548
|
+
console.log(` ${ts} ${type} ${persona} ${meta}`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function watchersCmd(args) {
|
|
553
|
+
const { flags } = parseFlags(args);
|
|
554
|
+
const personaQ = flags.persona ? `?personaSlug=${flags.persona}&activeOnly=true` : "?activeOnly=true";
|
|
555
|
+
const r = await apiGet(`/api/watchers${personaQ}`);
|
|
556
|
+
const list = r.watchers ?? [];
|
|
557
|
+
console.log(bold(`▶ active watchers (${list.length}건)`));
|
|
558
|
+
for (const w of list) {
|
|
559
|
+
const type = pad(w.spec?.condition?.type ?? "?", 6);
|
|
560
|
+
const persona = pad(w.personaSlug ?? "—", 8);
|
|
561
|
+
const intent = (w.spec?.intent ?? "").slice(0, 70);
|
|
562
|
+
console.log(` ${pad(w.id, 14)} ${type} ${persona} ${intent}`);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function prepareCmd() {
|
|
567
|
+
console.log("→ 시세 데이터 준비 시작 (첫 install 5~10분 · 매일 갱신은 수 초)");
|
|
568
|
+
// 1) start
|
|
569
|
+
const start = await apiPost("/api/prepare/start", {});
|
|
570
|
+
if (!start.started) {
|
|
571
|
+
console.log(yellow("⚠ 이미 진행 중 — 같은 진행 polling"));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// 2) polling (1초 간격)
|
|
575
|
+
let lastPages = -1;
|
|
576
|
+
let lastReturned = -1;
|
|
577
|
+
while (true) {
|
|
578
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
579
|
+
const s = await apiGet("/api/prepare/status");
|
|
580
|
+
|
|
581
|
+
// running 인 동안 progress 표시 (변화 있을 때만)
|
|
582
|
+
if (s.running) {
|
|
583
|
+
const u = s.universe.done ? green(`✓ universe ${s.universe.count}`) : "⏳ universe";
|
|
584
|
+
const cP = s.candles.pages;
|
|
585
|
+
const cR = s.candles.returned;
|
|
586
|
+
if (cP !== lastPages || cR !== lastReturned) {
|
|
587
|
+
const elapsed = Math.round((Date.now() - s.startedAt) / 1000);
|
|
588
|
+
process.stdout.write(
|
|
589
|
+
`\r ${u} · candles page ${cP} · ${cR.toLocaleString()} row · ${elapsed}s 경과 `,
|
|
590
|
+
);
|
|
591
|
+
lastPages = cP;
|
|
592
|
+
lastReturned = cR;
|
|
593
|
+
}
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// 종료 (running=false)
|
|
598
|
+
process.stdout.write("\n");
|
|
599
|
+
if (s.ok) {
|
|
600
|
+
const sec = Math.round(((s.finishedAt ?? 0) - (s.startedAt ?? 0)) / 1000);
|
|
601
|
+
console.log(green(`✓ 준비 완료 (${sec}s · ${(sec / 60).toFixed(1)}분)`));
|
|
602
|
+
console.log(` ${pad("universe", 12)} ${s.universe.count.toLocaleString()} 종목`);
|
|
603
|
+
console.log(` ${pad("candles", 12)} ${s.candles.returned.toLocaleString()} row · ${s.candles.pages} page`);
|
|
604
|
+
console.log("\n이제 mulsok-client cycle <slug> 가능합니다");
|
|
605
|
+
} else {
|
|
606
|
+
console.error(red(`✗ 실패: ${s.error ?? "unknown"}`));
|
|
607
|
+
process.exit(1);
|
|
608
|
+
}
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async function shareCmd(args) {
|
|
614
|
+
const sub = args[0];
|
|
615
|
+
if (sub === "dump" || !sub) {
|
|
616
|
+
console.log("→ admin 시세 스냅샷 export · VACUUM INTO + gzip (수 분 소요 가능)");
|
|
617
|
+
const t0 = Date.now();
|
|
618
|
+
const r = await apiPost("/api/admin/share/dump", {});
|
|
619
|
+
const sec = Math.round((Date.now() - t0) / 1000);
|
|
620
|
+
if (!r.ok) {
|
|
621
|
+
console.error(red(`✗ 실패 (${sec}s): ${r.error ?? "unknown"}`));
|
|
622
|
+
process.exit(1);
|
|
623
|
+
}
|
|
624
|
+
const rawMb = ((r.bytes ?? 0) / 1e6).toFixed(1);
|
|
625
|
+
const gzMb = ((r.bytesGzip ?? 0) / 1e6).toFixed(1);
|
|
626
|
+
const compPct = (100 - (r.compressionRatio ?? 0) * 100).toFixed(0);
|
|
627
|
+
console.log(green(`✓ 완료 (${sec}s)`));
|
|
628
|
+
console.log(` ${pad("path", 10)} ${r.path}`);
|
|
629
|
+
console.log(` ${pad("raw", 10)} ${rawMb}MB`);
|
|
630
|
+
console.log(` ${pad("gzip", 10)} ${gzMb}MB (${compPct}% 압축)`);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (sub === "publish-postgres") {
|
|
634
|
+
const { flags } = parseFlags(args.slice(1));
|
|
635
|
+
const body = {};
|
|
636
|
+
if (flags["universe-only"]) body.universeOnly = true;
|
|
637
|
+
if (flags["candles-only"]) body.candlesOnly = true;
|
|
638
|
+
console.log("→ admin SQLite → Supabase Postgres publish · 30~60분 소요 (10M+ row chunk upsert)");
|
|
639
|
+
console.log(" 진행 로그는 mulsok-client logs 로 실시간 확인");
|
|
640
|
+
const t0 = Date.now();
|
|
641
|
+
const r = await apiPost("/api/admin/share/publish-postgres", body);
|
|
642
|
+
const sec = Math.round((Date.now() - t0) / 1000);
|
|
643
|
+
if (!r.ok) {
|
|
644
|
+
console.error(red(`✗ 실패 (${sec}s): ${r.error ?? "unknown"}`));
|
|
645
|
+
process.exit(1);
|
|
646
|
+
}
|
|
647
|
+
console.log(green(`✓ 완료 (${sec}s · ${(sec / 60).toFixed(1)}분)`));
|
|
648
|
+
console.log(` ${pad("universe", 10)} ${(r.universeRows ?? 0).toLocaleString()} rows`);
|
|
649
|
+
console.log(` ${pad("candles", 10)} ${(r.candleRows ?? 0).toLocaleString()} rows`);
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
console.error(red(`✗ 알 수 없는 share 명령: ${sub}`));
|
|
653
|
+
console.error(" 사용: dump · publish-postgres [--universe-only|--candles-only]");
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function configCmd(args) {
|
|
658
|
+
const sub = args[0];
|
|
659
|
+
switch (sub) {
|
|
660
|
+
case "show":
|
|
661
|
+
case undefined:
|
|
662
|
+
configShow(await apiGet("/api/config"));
|
|
663
|
+
return;
|
|
664
|
+
case "token":
|
|
665
|
+
await configToken(args.slice(1));
|
|
666
|
+
return;
|
|
667
|
+
case "llm":
|
|
668
|
+
await configLlm(args.slice(1));
|
|
669
|
+
return;
|
|
670
|
+
case "broker":
|
|
671
|
+
await configBroker(args.slice(1));
|
|
672
|
+
return;
|
|
673
|
+
case "subs":
|
|
674
|
+
case "subscriptions":
|
|
675
|
+
await configSubs(args.slice(1));
|
|
676
|
+
return;
|
|
677
|
+
default:
|
|
678
|
+
console.error(red(`✗ 알 수 없는 config 명령: ${sub}`));
|
|
679
|
+
console.error(" 사용: show / token / llm / broker / subs");
|
|
680
|
+
process.exit(1);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
128
684
|
switch (cmd) {
|
|
129
685
|
case "install":
|
|
130
686
|
if (!isMac()) {
|
|
@@ -141,7 +697,7 @@ switch (cmd) {
|
|
|
141
697
|
runScript("uninstall-daemon.sh");
|
|
142
698
|
break;
|
|
143
699
|
case "status":
|
|
144
|
-
status();
|
|
700
|
+
await status(process.argv.includes("--json"));
|
|
145
701
|
break;
|
|
146
702
|
case "logs":
|
|
147
703
|
logs();
|
|
@@ -149,6 +705,30 @@ switch (cmd) {
|
|
|
149
705
|
case "update":
|
|
150
706
|
update();
|
|
151
707
|
break;
|
|
708
|
+
case "config":
|
|
709
|
+
await configCmd(process.argv.slice(3));
|
|
710
|
+
break;
|
|
711
|
+
case "safety":
|
|
712
|
+
await safetyCmd(process.argv.slice(3));
|
|
713
|
+
break;
|
|
714
|
+
case "cycle":
|
|
715
|
+
await cycleCmd(process.argv.slice(3));
|
|
716
|
+
break;
|
|
717
|
+
case "diary":
|
|
718
|
+
await diaryCmd(process.argv.slice(3));
|
|
719
|
+
break;
|
|
720
|
+
case "journal":
|
|
721
|
+
await journalCmd(process.argv.slice(3));
|
|
722
|
+
break;
|
|
723
|
+
case "watchers":
|
|
724
|
+
await watchersCmd(process.argv.slice(3));
|
|
725
|
+
break;
|
|
726
|
+
case "share":
|
|
727
|
+
await shareCmd(process.argv.slice(3));
|
|
728
|
+
break;
|
|
729
|
+
case "prepare":
|
|
730
|
+
await prepareCmd();
|
|
731
|
+
break;
|
|
152
732
|
case "help":
|
|
153
733
|
case undefined:
|
|
154
734
|
help();
|