tickflow-assist 0.3.5 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +9 -40
  2. package/dist/analysis/types/composite-analysis.d.ts +27 -0
  3. package/dist/bootstrap.js +19 -5
  4. package/dist/config/tickflow-access.d.ts +2 -1
  5. package/dist/config/tickflow-access.js +10 -3
  6. package/dist/dev/tickflow-assist-cli.js +4 -3
  7. package/dist/dev/validate-mx-search.js +10 -2
  8. package/dist/plugin.js +4 -6
  9. package/dist/prompts/analysis/kline-analysis-user-prompt.js +2 -1
  10. package/dist/prompts/analysis/post-close-review-user-prompt.js +40 -1
  11. package/dist/prompts/analysis/pre-market-brief-prompt.d.ts +3 -1
  12. package/dist/prompts/analysis/pre-market-brief-prompt.js +5 -1
  13. package/dist/services/alert-service.d.ts +8 -0
  14. package/dist/services/alert-service.js +327 -45
  15. package/dist/services/industry-peer-service.d.ts +9 -0
  16. package/dist/services/industry-peer-service.js +152 -0
  17. package/dist/services/jin10-flash-monitor-service.js +2 -1
  18. package/dist/services/monitor-service.d.ts +4 -1
  19. package/dist/services/monitor-service.js +51 -20
  20. package/dist/services/post-close-review-service.d.ts +11 -4
  21. package/dist/services/post-close-review-service.js +113 -10
  22. package/dist/services/pre-market-brief-service.js +165 -11
  23. package/dist/services/tickflow-client.d.ts +4 -1
  24. package/dist/services/tickflow-client.js +32 -0
  25. package/dist/services/tickflow-universe-service.d.ts +26 -0
  26. package/dist/services/tickflow-universe-service.js +213 -0
  27. package/dist/services/watchlist-profile-service.d.ts +4 -1
  28. package/dist/services/watchlist-profile-service.js +58 -29
  29. package/dist/services/watchlist-service.js +1 -1
  30. package/dist/storage/repositories/universe-membership-repo.d.ts +11 -0
  31. package/dist/storage/repositories/universe-membership-repo.js +38 -0
  32. package/dist/storage/repositories/universe-repo.d.ts +17 -0
  33. package/dist/storage/repositories/universe-repo.js +62 -0
  34. package/dist/storage/schemas.d.ts +2 -0
  35. package/dist/storage/schemas.js +13 -0
  36. package/dist/tools/add-stock.tool.d.ts +2 -1
  37. package/dist/tools/add-stock.tool.js +10 -1
  38. package/dist/tools/query-database.tool.js +6 -0
  39. package/dist/tools/refresh-watchlist-profiles.tool.d.ts +2 -1
  40. package/dist/tools/refresh-watchlist-profiles.tool.js +11 -1
  41. package/dist/tools/test-alert.tool.js +58 -16
  42. package/dist/types/tickflow.d.ts +12 -0
  43. package/dist/utils/alert-diagnostic-log.d.ts +12 -0
  44. package/dist/utils/alert-diagnostic-log.js +60 -0
  45. package/dist/utils/tickflow-quote.d.ts +5 -0
  46. package/dist/utils/tickflow-quote.js +31 -0
  47. package/openclaw.plugin.json +108 -2
  48. package/package.json +10 -5
  49. package/skills/stock-analysis/SKILL.md +9 -20
@@ -39,6 +39,38 @@ export class TickFlowClient {
39
39
  });
40
40
  return response.data ?? [];
41
41
  }
42
+ async listUniverses() {
43
+ const url = new URL("/v1/universes", this.baseUrl);
44
+ const response = await this.requestJson(url.toString(), {
45
+ method: "GET",
46
+ });
47
+ return response.data ?? [];
48
+ }
49
+ async fetchUniverse(id) {
50
+ const normalizedId = String(id ?? "").trim();
51
+ if (!normalizedId) {
52
+ return null;
53
+ }
54
+ const url = new URL(`/v1/universes/${encodeURIComponent(normalizedId)}`, this.baseUrl);
55
+ const response = await this.requestJson(url.toString(), {
56
+ method: "GET",
57
+ });
58
+ return response.data ?? null;
59
+ }
60
+ async fetchUniverseBatch(ids) {
61
+ const normalizedIds = ids
62
+ .map((id) => String(id ?? "").trim())
63
+ .filter(Boolean);
64
+ if (normalizedIds.length === 0) {
65
+ return {};
66
+ }
67
+ const url = new URL("/v1/universes/batch", this.baseUrl);
68
+ const response = await this.requestJson(url.toString(), {
69
+ method: "POST",
70
+ body: JSON.stringify({ ids: normalizedIds }),
71
+ });
72
+ return response.data ?? {};
73
+ }
42
74
  async fetchKlinesBatch(symbols, params = {}) {
43
75
  if (symbols.length === 0) {
44
76
  return { data: {} };
@@ -0,0 +1,26 @@
1
+ import { TickFlowClient } from "./tickflow-client.js";
2
+ import { UniverseMembershipRepository } from "../storage/repositories/universe-membership-repo.js";
3
+ import { UniverseRepository } from "../storage/repositories/universe-repo.js";
4
+ export interface TickFlowIndustryProfile {
5
+ sectorPath: string | null;
6
+ sw1Name: string | null;
7
+ sw2Name: string | null;
8
+ sw3Name: string | null;
9
+ sw1UniverseId: string | null;
10
+ sw2UniverseId: string | null;
11
+ sw3UniverseId: string | null;
12
+ industryCode: string | null;
13
+ }
14
+ export declare class TickFlowUniverseService {
15
+ private readonly client;
16
+ private readonly universeRepository;
17
+ private readonly membershipRepository;
18
+ private catalog;
19
+ constructor(client: TickFlowClient, universeRepository: UniverseRepository, membershipRepository: UniverseMembershipRepository);
20
+ resolveIndustryProfile(symbol: string): Promise<TickFlowIndustryProfile | null>;
21
+ listUniverseSymbols(universeId: string): Promise<string[]>;
22
+ private ensureCatalog;
23
+ private loadCatalogFromRepositories;
24
+ private syncCatalogFromTickFlow;
25
+ private fetchUniverseDetails;
26
+ }
@@ -0,0 +1,213 @@
1
+ import { formatChinaDateTime } from "../utils/china-time.js";
2
+ const UNIVERSE_BATCH_SIZE = 50;
3
+ const UNIVERSE_CACHE_REFRESH_MS = 24 * 60 * 60 * 1000;
4
+ const SHENWAN_UNIVERSE_PATTERN = /^CN_Equity_(SW[123])_(\d{6})$/;
5
+ export class TickFlowUniverseService {
6
+ client;
7
+ universeRepository;
8
+ membershipRepository;
9
+ catalog = null;
10
+ constructor(client, universeRepository, membershipRepository) {
11
+ this.client = client;
12
+ this.universeRepository = universeRepository;
13
+ this.membershipRepository = membershipRepository;
14
+ }
15
+ async resolveIndustryProfile(symbol) {
16
+ const catalog = await this.ensureCatalog();
17
+ const universeIds = catalog.membershipUniverseIdsBySymbol.get(symbol) ?? [];
18
+ if (universeIds.length === 0) {
19
+ return null;
20
+ }
21
+ const summaries = universeIds
22
+ .map((id) => catalog.summariesById.get(id))
23
+ .filter((item) => item != null)
24
+ .map((summary) => ({
25
+ summary,
26
+ shenwan: parseShenwanUniverse(summary),
27
+ }))
28
+ .filter((item) => item.shenwan != null);
29
+ if (summaries.length === 0) {
30
+ return null;
31
+ }
32
+ const sw1 = summaries.find((item) => item.shenwan.level === "SW1") ?? null;
33
+ const sw2 = summaries.find((item) => item.shenwan.level === "SW2") ?? null;
34
+ const sw3 = summaries.find((item) => item.shenwan.level === "SW3") ?? null;
35
+ const names = [sw1?.shenwan.label, sw2?.shenwan.label, sw3?.shenwan.label]
36
+ .filter((value) => Boolean(value));
37
+ return {
38
+ sectorPath: names.length > 0 ? names.join("-") : null,
39
+ sw1Name: sw1?.shenwan.label ?? null,
40
+ sw2Name: sw2?.shenwan.label ?? null,
41
+ sw3Name: sw3?.shenwan.label ?? null,
42
+ sw1UniverseId: sw1?.summary.id ?? null,
43
+ sw2UniverseId: sw2?.summary.id ?? null,
44
+ sw3UniverseId: sw3?.summary.id ?? null,
45
+ industryCode: sw3?.shenwan.code ?? sw2?.shenwan.code ?? sw1?.shenwan.code ?? null,
46
+ };
47
+ }
48
+ async listUniverseSymbols(universeId) {
49
+ const catalog = await this.ensureCatalog();
50
+ return [...(catalog.symbolsByUniverseId.get(universeId) ?? [])];
51
+ }
52
+ async ensureCatalog(force = false) {
53
+ if (!force && this.catalog && Date.now() - this.catalog.syncedAtTs < UNIVERSE_CACHE_REFRESH_MS) {
54
+ return this.catalog;
55
+ }
56
+ const localCatalog = await this.loadCatalogFromRepositories();
57
+ if (!force && localCatalog && Date.now() - localCatalog.syncedAtTs < UNIVERSE_CACHE_REFRESH_MS) {
58
+ this.catalog = localCatalog;
59
+ return localCatalog;
60
+ }
61
+ try {
62
+ const syncedCatalog = await this.syncCatalogFromTickFlow();
63
+ this.catalog = syncedCatalog;
64
+ return syncedCatalog;
65
+ }
66
+ catch (error) {
67
+ if (localCatalog) {
68
+ console.warn(`[tickflow-universe] remote sync failed, falling back to local cache: ${toErrorMessage(error)}`);
69
+ this.catalog = localCatalog;
70
+ return localCatalog;
71
+ }
72
+ throw error;
73
+ }
74
+ }
75
+ async loadCatalogFromRepositories() {
76
+ const [summaries, memberships] = await Promise.all([
77
+ this.universeRepository.list(),
78
+ this.membershipRepository.list(),
79
+ ]);
80
+ if (summaries.length === 0 || memberships.length === 0) {
81
+ return null;
82
+ }
83
+ return buildCachedCatalog(summaries, memberships);
84
+ }
85
+ async syncCatalogFromTickFlow() {
86
+ const summaries = await this.client.listUniverses();
87
+ if (summaries.length === 0) {
88
+ throw new Error("TickFlow universe list is empty");
89
+ }
90
+ const details = await this.fetchUniverseDetails(summaries);
91
+ const syncedAt = formatChinaDateTime();
92
+ const universeRows = summaries.map((summary) => details[summary.id] ?? summary);
93
+ const memberships = universeRows.flatMap((item) => toUniverseMembershipEntries(item));
94
+ if (memberships.length === 0) {
95
+ throw new Error("TickFlow universe detail sync returned no membership rows");
96
+ }
97
+ await Promise.all([
98
+ this.universeRepository.replaceAll(universeRows, syncedAt),
99
+ this.membershipRepository.replaceAll(memberships),
100
+ ]);
101
+ const cached = buildCachedCatalog(universeRows.map((item) => ({
102
+ id: item.id,
103
+ name: item.name,
104
+ description: item.description ?? null,
105
+ region: item.region,
106
+ category: item.category,
107
+ symbolCount: Math.max(0, Math.trunc(Number(item.symbol_count ?? 0))),
108
+ syncedAt,
109
+ })), memberships);
110
+ this.catalog = cached;
111
+ return cached;
112
+ }
113
+ async fetchUniverseDetails(summaries) {
114
+ const details = {};
115
+ const ids = summaries.map((summary) => summary.id);
116
+ for (let index = 0; index < ids.length; index += UNIVERSE_BATCH_SIZE) {
117
+ const chunk = ids.slice(index, index + UNIVERSE_BATCH_SIZE);
118
+ const result = await this.client.fetchUniverseBatch(chunk);
119
+ for (const [id, detail] of Object.entries(result)) {
120
+ details[id] = detail;
121
+ }
122
+ const missingIds = chunk.filter((id) => details[id] == null);
123
+ for (const missingId of missingIds) {
124
+ const detail = await this.client.fetchUniverse(missingId);
125
+ if (detail) {
126
+ details[missingId] = detail;
127
+ }
128
+ }
129
+ }
130
+ return details;
131
+ }
132
+ }
133
+ function buildCachedCatalog(summaries, memberships) {
134
+ const summariesById = new Map(summaries.map((item) => [item.id, item]));
135
+ const membershipUniverseIdsBySymbol = new Map();
136
+ const symbolsByUniverseId = new Map();
137
+ for (const membership of memberships) {
138
+ pushUnique(membershipUniverseIdsBySymbol, membership.symbol, membership.universeId);
139
+ pushUnique(symbolsByUniverseId, membership.universeId, membership.symbol);
140
+ }
141
+ const syncedAtTs = summaries.reduce((latest, item) => {
142
+ const timestamp = Date.parse(toIsoLikeTimestamp(item.syncedAt));
143
+ return Number.isFinite(timestamp) ? Math.max(latest, timestamp) : latest;
144
+ }, 0);
145
+ return {
146
+ summariesById,
147
+ membershipUniverseIdsBySymbol,
148
+ symbolsByUniverseId,
149
+ syncedAtTs,
150
+ };
151
+ }
152
+ function parseShenwanUniverse(summary) {
153
+ const match = summary.id.match(SHENWAN_UNIVERSE_PATTERN);
154
+ if (!match) {
155
+ return null;
156
+ }
157
+ const [, level = "", code = ""] = match;
158
+ const label = extractUniverseLabel(summary.name, summary.description);
159
+ if (!label) {
160
+ return null;
161
+ }
162
+ return {
163
+ level: level,
164
+ code,
165
+ label,
166
+ };
167
+ }
168
+ function extractUniverseLabel(name, description) {
169
+ const descriptionLabel = String(description ?? "")
170
+ .replace(/^申万[123]级行业[::]\s*/, "")
171
+ .trim();
172
+ if (descriptionLabel) {
173
+ return descriptionLabel;
174
+ }
175
+ const nameLabel = String(name ?? "")
176
+ .replace(/^SW[123]/, "")
177
+ .trim();
178
+ return nameLabel || null;
179
+ }
180
+ function toUniverseMembershipEntries(item) {
181
+ if (!("symbols" in item) || !Array.isArray(item.symbols)) {
182
+ return [];
183
+ }
184
+ return item.symbols
185
+ .map((symbol) => String(symbol ?? "").trim())
186
+ .filter(Boolean)
187
+ .map((symbol) => ({
188
+ universeId: item.id,
189
+ symbol,
190
+ }));
191
+ }
192
+ function pushUnique(map, key, value) {
193
+ const existing = map.get(key);
194
+ if (existing) {
195
+ if (!existing.includes(value)) {
196
+ existing.push(value);
197
+ }
198
+ return;
199
+ }
200
+ map.set(key, [value]);
201
+ }
202
+ function toIsoLikeTimestamp(value) {
203
+ const text = value.trim();
204
+ if (!text) {
205
+ return text;
206
+ }
207
+ return /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(text)
208
+ ? `${text.replace(" ", "T")}+08:00`
209
+ : text;
210
+ }
211
+ function toErrorMessage(error) {
212
+ return error instanceof Error ? error.message : String(error);
213
+ }
@@ -1,6 +1,7 @@
1
1
  import { AnalysisService } from "./analysis-service.js";
2
2
  import type { MxSearchDocument } from "../types/mx-search.js";
3
3
  import { MxApiService } from "./mx-search-service.js";
4
+ import { TickFlowUniverseService } from "./tickflow-universe-service.js";
4
5
  export interface WatchlistProfile {
5
6
  sector: string | null;
6
7
  themes: string[];
@@ -8,13 +9,15 @@ export interface WatchlistProfile {
8
9
  themeUpdatedAt: string | null;
9
10
  }
10
11
  export declare class WatchlistProfileService {
12
+ private readonly tickFlowUniverseService;
11
13
  private readonly mxApiService;
12
14
  private readonly analysisService;
13
- constructor(mxApiService: MxApiService, analysisService: AnalysisService);
15
+ constructor(tickFlowUniverseService: TickFlowUniverseService | null, mxApiService: MxApiService, analysisService: AnalysisService);
14
16
  resolve(symbol: string, companyName: string, updatedAt: string): Promise<WatchlistProfile>;
15
17
  }
16
18
  export declare function buildBoardNewsQuery(profile: {
17
19
  sector: string | null;
18
20
  themes: string[];
19
21
  }): string | null;
22
+ export declare function extractSectorKeywords(sector: string | null | undefined): string[];
20
23
  export declare function formatWatchlistProfileDocuments(documents: MxSearchDocument[]): string;
@@ -2,46 +2,58 @@ import { parseWatchlistProfileExtraction } from "../analysis/parsers/watchlist-p
2
2
  import { buildWatchlistProfileExtractionUserPrompt, WATCHLIST_PROFILE_EXTRACTION_SYSTEM_PROMPT, } from "../prompts/analysis/index.js";
3
3
  const MAX_PROFILE_DOCUMENTS = 8;
4
4
  export class WatchlistProfileService {
5
+ tickFlowUniverseService;
5
6
  mxApiService;
6
7
  analysisService;
7
- constructor(mxApiService, analysisService) {
8
+ constructor(tickFlowUniverseService, mxApiService, analysisService) {
9
+ this.tickFlowUniverseService = tickFlowUniverseService;
8
10
  this.mxApiService = mxApiService;
9
11
  this.analysisService = analysisService;
10
12
  }
11
13
  async resolve(symbol, companyName, updatedAt) {
12
14
  const themeQuery = buildThemeQuery(companyName, symbol);
15
+ const industryProfile = this.tickFlowUniverseService
16
+ ? await this.tickFlowUniverseService.resolveIndustryProfile(symbol)
17
+ .catch((error) => {
18
+ console.warn(`[watchlist-profile] tickflow universe lookup skipped for ${symbol}: ${toErrorMessage(error)}`);
19
+ return null;
20
+ })
21
+ : null;
13
22
  const mxConfigError = this.mxApiService.getConfigurationError();
14
- if (mxConfigError) {
15
- throw new Error(mxConfigError);
16
- }
17
23
  const llmConfigError = this.analysisService.getConfigurationError();
18
- if (llmConfigError) {
19
- throw new Error(llmConfigError);
20
- }
21
- const documents = (await this.mxApiService.search(themeQuery)).slice(0, MAX_PROFILE_DOCUMENTS);
22
- if (documents.length === 0) {
23
- return {
24
- sector: null,
25
- themes: [],
26
- themeQuery,
27
- themeUpdatedAt: updatedAt,
28
- };
24
+ const canUseMx = !mxConfigError && !llmConfigError;
25
+ let parsedProfile = null;
26
+ if (canUseMx) {
27
+ try {
28
+ const documents = (await this.mxApiService.search(themeQuery)).slice(0, MAX_PROFILE_DOCUMENTS);
29
+ if (documents.length > 0) {
30
+ const responseText = await this.analysisService.generateText(WATCHLIST_PROFILE_EXTRACTION_SYSTEM_PROMPT, buildWatchlistProfileExtractionUserPrompt({
31
+ symbol,
32
+ companyName,
33
+ documents,
34
+ }), {
35
+ maxTokens: 1200,
36
+ temperature: 0.1,
37
+ });
38
+ parsedProfile = parseWatchlistProfileExtraction(responseText);
39
+ if (!parsedProfile) {
40
+ throw new Error(`watchlist profile extraction returned invalid JSON for ${symbol}`);
41
+ }
42
+ }
43
+ }
44
+ catch (error) {
45
+ if (!industryProfile) {
46
+ throw error;
47
+ }
48
+ console.warn(`[watchlist-profile] mx profile enrichment skipped for ${symbol}: ${toErrorMessage(error)}`);
49
+ }
29
50
  }
30
- const responseText = await this.analysisService.generateText(WATCHLIST_PROFILE_EXTRACTION_SYSTEM_PROMPT, buildWatchlistProfileExtractionUserPrompt({
31
- symbol,
32
- companyName,
33
- documents,
34
- }), {
35
- maxTokens: 1200,
36
- temperature: 0.1,
37
- });
38
- const profile = parseWatchlistProfileExtraction(responseText);
39
- if (!profile) {
40
- throw new Error(`watchlist profile extraction returned invalid JSON for ${symbol}`);
51
+ else if (!industryProfile) {
52
+ throw new Error(mxConfigError ?? llmConfigError ?? "watchlist profile service unavailable");
41
53
  }
42
54
  return {
43
- sector: profile.sector,
44
- themes: profile.themes,
55
+ sector: industryProfile?.sectorPath ?? parsedProfile?.sector ?? null,
56
+ themes: parsedProfile?.themes ?? [],
45
57
  themeQuery,
46
58
  themeUpdatedAt: updatedAt,
47
59
  };
@@ -49,7 +61,7 @@ export class WatchlistProfileService {
49
61
  }
50
62
  export function buildBoardNewsQuery(profile) {
51
63
  const keywords = [
52
- String(profile.sector ?? "").trim(),
64
+ ...extractSectorKeywords(profile.sector),
53
65
  ...profile.themes
54
66
  .map((item) => String(item ?? "").trim())
55
67
  .filter(Boolean)
@@ -60,6 +72,17 @@ export function buildBoardNewsQuery(profile) {
60
72
  }
61
73
  return `${keywords.join(" ")} 板块 题材 最新新闻 政策 资金`;
62
74
  }
75
+ export function extractSectorKeywords(sector) {
76
+ const raw = String(sector ?? "").trim();
77
+ if (!raw) {
78
+ return [];
79
+ }
80
+ const keywords = raw
81
+ .split(/[-/|>|→|]/)
82
+ .map((item) => item.trim())
83
+ .filter(Boolean);
84
+ return uniqueStrings(keywords.length > 0 ? keywords : [raw]);
85
+ }
63
86
  function buildThemeQuery(companyName, symbol) {
64
87
  return `${companyName} ${symbol} 所属行业 板块 题材 概念`;
65
88
  }
@@ -74,3 +97,9 @@ export function formatWatchlistProfileDocuments(documents) {
74
97
  ].join("\n"))
75
98
  .join("\n\n");
76
99
  }
100
+ function uniqueStrings(values) {
101
+ return [...new Set(values.filter(Boolean))];
102
+ }
103
+ function toErrorMessage(error) {
104
+ return error instanceof Error ? error.message : String(error);
105
+ }
@@ -160,7 +160,7 @@ export class WatchlistService {
160
160
  }
161
161
  }
162
162
  function shouldRefreshConceptBoards(item) {
163
- if (!item.sector || item.themes.length === 0 || !item.themeUpdatedAt) {
163
+ if (!item.sector || !item.themeUpdatedAt) {
164
164
  return true;
165
165
  }
166
166
  const updatedAt = parseChinaTimestamp(item.themeUpdatedAt);
@@ -0,0 +1,11 @@
1
+ import { Database } from "../db.js";
2
+ export interface UniverseMembershipEntry {
3
+ universeId: string;
4
+ symbol: string;
5
+ }
6
+ export declare class UniverseMembershipRepository {
7
+ private readonly db;
8
+ constructor(db: Database);
9
+ list(): Promise<UniverseMembershipEntry[]>;
10
+ replaceAll(entries: UniverseMembershipEntry[]): Promise<void>;
11
+ }
@@ -0,0 +1,38 @@
1
+ import { universeMembershipSchema } from "../schemas.js";
2
+ const UNIVERSE_MEMBERSHIP_TABLE = "universe_memberships";
3
+ export class UniverseMembershipRepository {
4
+ db;
5
+ constructor(db) {
6
+ this.db = db;
7
+ }
8
+ async list() {
9
+ if (!(await this.db.hasTable(UNIVERSE_MEMBERSHIP_TABLE))) {
10
+ return [];
11
+ }
12
+ const rows = await this.db.tableToArray(UNIVERSE_MEMBERSHIP_TABLE);
13
+ return rows
14
+ .map((row) => ({
15
+ universeId: String(row.universeId ?? row.universe_id ?? "").trim(),
16
+ symbol: String(row.symbol ?? "").trim(),
17
+ }))
18
+ .filter((row) => row.universeId && row.symbol);
19
+ }
20
+ async replaceAll(entries) {
21
+ const rows = entries.map(toUniverseMembershipRow);
22
+ if (rows.length === 0) {
23
+ return;
24
+ }
25
+ if (!(await this.db.hasTable(UNIVERSE_MEMBERSHIP_TABLE))) {
26
+ await this.db.createTable(UNIVERSE_MEMBERSHIP_TABLE, rows, universeMembershipSchema);
27
+ return;
28
+ }
29
+ const table = await this.db.openTable(UNIVERSE_MEMBERSHIP_TABLE);
30
+ await table.add(rows, { mode: "overwrite" });
31
+ }
32
+ }
33
+ function toUniverseMembershipRow(entry) {
34
+ return {
35
+ universeId: entry.universeId,
36
+ symbol: entry.symbol,
37
+ };
38
+ }
@@ -0,0 +1,17 @@
1
+ import type { TickFlowUniverseSummary } from "../../types/tickflow.js";
2
+ import { Database } from "../db.js";
3
+ export interface StoredUniverseSummary {
4
+ id: string;
5
+ name: string;
6
+ description: string | null;
7
+ region: string;
8
+ category: string;
9
+ symbolCount: number;
10
+ syncedAt: string;
11
+ }
12
+ export declare class UniverseRepository {
13
+ private readonly db;
14
+ constructor(db: Database);
15
+ list(): Promise<StoredUniverseSummary[]>;
16
+ replaceAll(universes: TickFlowUniverseSummary[], syncedAt: string): Promise<void>;
17
+ }
@@ -0,0 +1,62 @@
1
+ import { universeSchema } from "../schemas.js";
2
+ const UNIVERSE_TABLE = "universes";
3
+ export class UniverseRepository {
4
+ db;
5
+ constructor(db) {
6
+ this.db = db;
7
+ }
8
+ async list() {
9
+ if (!(await this.db.hasTable(UNIVERSE_TABLE))) {
10
+ return [];
11
+ }
12
+ const rows = await this.db.tableToArray(UNIVERSE_TABLE);
13
+ return rows
14
+ .map((row) => ({
15
+ id: String(row.id ?? "").trim(),
16
+ name: String(row.name ?? "").trim(),
17
+ description: normalizeNullableString(row.description),
18
+ region: String(row.region ?? "").trim(),
19
+ category: String(row.category ?? "").trim(),
20
+ symbolCount: normalizeNonNegativeInteger(row.symbolCount ?? row.symbol_count),
21
+ syncedAt: String(row.syncedAt ?? row.synced_at ?? "").trim(),
22
+ }))
23
+ .filter((row) => row.id && row.name);
24
+ }
25
+ async replaceAll(universes, syncedAt) {
26
+ const rows = universes.map((item) => toUniverseRow(item, syncedAt));
27
+ if (rows.length === 0) {
28
+ return;
29
+ }
30
+ if (!(await this.db.hasTable(UNIVERSE_TABLE))) {
31
+ await this.db.createTable(UNIVERSE_TABLE, rows, universeSchema);
32
+ return;
33
+ }
34
+ const table = await this.db.openTable(UNIVERSE_TABLE);
35
+ await table.add(rows, { mode: "overwrite" });
36
+ }
37
+ }
38
+ function toUniverseRow(item, syncedAt) {
39
+ return {
40
+ id: item.id,
41
+ name: item.name,
42
+ description: item.description ?? "",
43
+ region: item.region,
44
+ category: item.category,
45
+ symbolCount: Math.max(0, Math.trunc(Number(item.symbol_count ?? 0))),
46
+ syncedAt,
47
+ };
48
+ }
49
+ function normalizeNullableString(value) {
50
+ if (typeof value !== "string") {
51
+ return null;
52
+ }
53
+ const text = value.trim();
54
+ return text || null;
55
+ }
56
+ function normalizeNonNegativeInteger(value) {
57
+ const numeric = Number(value);
58
+ if (!Number.isFinite(numeric) || numeric < 0) {
59
+ return 0;
60
+ }
61
+ return Math.trunc(numeric);
62
+ }
@@ -1,5 +1,7 @@
1
1
  import { Schema } from "apache-arrow";
2
2
  export declare const watchlistSchema: Schema<any>;
3
+ export declare const universeSchema: Schema<any>;
4
+ export declare const universeMembershipSchema: Schema<any>;
3
5
  export declare const klinesDailySchema: Schema<any>;
4
6
  export declare const klinesIntradaySchema: Schema<any>;
5
7
  export declare const indicatorsSchema: Schema<any>;
@@ -9,6 +9,19 @@ export const watchlistSchema = new Schema([
9
9
  new Field("themeQuery", new Utf8(), true),
10
10
  new Field("themeUpdatedAt", new Utf8(), true),
11
11
  ]);
12
+ export const universeSchema = new Schema([
13
+ new Field("id", new Utf8(), false),
14
+ new Field("name", new Utf8(), false),
15
+ new Field("description", new Utf8(), true),
16
+ new Field("region", new Utf8(), false),
17
+ new Field("category", new Utf8(), false),
18
+ new Field("symbolCount", new Int64(), false),
19
+ new Field("syncedAt", new Utf8(), false),
20
+ ]);
21
+ export const universeMembershipSchema = new Schema([
22
+ new Field("universeId", new Utf8(), false),
23
+ new Field("symbol", new Utf8(), false),
24
+ ]);
12
25
  export const klinesDailySchema = new Schema([
13
26
  new Field("symbol", new Utf8(), false),
14
27
  new Field("trade_date", new Utf8(), false),
@@ -3,7 +3,8 @@ import { KlineService } from "../services/kline-service.js";
3
3
  import { KlinesRepository } from "../storage/repositories/klines-repo.js";
4
4
  import { IndicatorService } from "../services/indicator-service.js";
5
5
  import { IndicatorsRepository } from "../storage/repositories/indicators-repo.js";
6
- export declare function addStockTool(watchlistService: WatchlistService, klineService: KlineService, klinesRepository: KlinesRepository, indicatorService: IndicatorService, indicatorsRepository: IndicatorsRepository): {
6
+ import type { TickflowApiKeyLevel } from "../config/tickflow-access.js";
7
+ export declare function addStockTool(tickflowApiKeyLevel: TickflowApiKeyLevel, watchlistService: WatchlistService, klineService: KlineService, klinesRepository: KlinesRepository, indicatorService: IndicatorService, indicatorsRepository: IndicatorsRepository): {
7
8
  name: string;
8
9
  description: string;
9
10
  optional: boolean;
@@ -34,7 +34,7 @@ function parseOptionalPositiveNumber(value) {
34
34
  }
35
35
  return numeric;
36
36
  }
37
- export function addStockTool(watchlistService, klineService, klinesRepository, indicatorService, indicatorsRepository) {
37
+ export function addStockTool(tickflowApiKeyLevel, watchlistService, klineService, klinesRepository, indicatorService, indicatorsRepository) {
38
38
  return {
39
39
  name: "add_stock",
40
40
  description: "Add a symbol to the watchlist with optional cost price, then fetch daily K-lines and indicators.",
@@ -65,6 +65,7 @@ export function addStockTool(watchlistService, klineService, klinesRepository, i
65
65
  if (rows.length === 0) {
66
66
  return [
67
67
  ...buildAddStockPrefix(item, profileError),
68
+ buildProfileSourceHint(tickflowApiKeyLevel),
68
69
  `⚠️ 已尝试拉取 ${klineCount} 天日K,但返回数据为空`,
69
70
  ].filter(Boolean).join("\n");
70
71
  }
@@ -75,6 +76,7 @@ export function addStockTool(watchlistService, klineService, klinesRepository, i
75
76
  const last = rows[rows.length - 1];
76
77
  return [
77
78
  ...buildAddStockPrefix(item, profileError),
79
+ buildProfileSourceHint(tickflowApiKeyLevel),
78
80
  `📊 已自动获取日K: ${rows.length} 根`,
79
81
  `区间: ${first.trade_date} ~ ${last.trade_date}`,
80
82
  `最新收盘: ${last.close.toFixed(2)}`,
@@ -85,6 +87,7 @@ export function addStockTool(watchlistService, klineService, klinesRepository, i
85
87
  const message = error instanceof Error ? error.message : String(error);
86
88
  return [
87
89
  ...buildAddStockPrefix(item, profileError),
90
+ buildProfileSourceHint(tickflowApiKeyLevel),
88
91
  `⚠️ 自动拉取 ${klineCount} 天日K失败: ${message}`,
89
92
  ].filter(Boolean).join("\n");
90
93
  }
@@ -114,6 +117,12 @@ function formatWatchlistProfileWarning(profileError) {
114
117
  }
115
118
  return `⚠️ 行业分类/概念板块获取失败: ${profileError}`;
116
119
  }
120
+ function buildProfileSourceHint(tickflowApiKeyLevel) {
121
+ if (tickflowApiKeyLevel !== "free") {
122
+ return null;
123
+ }
124
+ return "ℹ️ 当前 TickFlow API Key Level 为 Free,标的池不可用,行业分类当前走妙想链路。";
125
+ }
117
126
  function formatAddStockFailure(symbol, error) {
118
127
  const message = error instanceof Error ? error.message : String(error);
119
128
  if (!symbol) {
@@ -5,6 +5,12 @@ const TABLE_ALIASES = {
5
5
  watchlists: "watchlist",
6
6
  自选: "watchlist",
7
7
  自选股: "watchlist",
8
+ universes: "universes",
9
+ universe: "universes",
10
+ 标的池: "universes",
11
+ universe_memberships: "universe_memberships",
12
+ universe_members: "universe_memberships",
13
+ 标的池成员: "universe_memberships",
8
14
  klines: "klines_daily",
9
15
  kline: "klines_daily",
10
16
  klines_daily: "klines_daily",