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