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