kalshi-trading-bot-cli 2.1.3 → 2.1.5
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/README.md +214 -5
- package/data/themes_seo.json +159 -0
- package/env.example +5 -2
- package/package.json +1 -1
- package/src/backtest/renderer.ts +0 -2
- package/src/cli.ts +89 -0
- package/src/commands/analyze-batch.ts +101 -0
- package/src/commands/analyze.ts +99 -17
- package/src/commands/basket.ts +653 -0
- package/src/commands/catalysts.ts +121 -0
- package/src/commands/clusters.ts +153 -0
- package/src/commands/correlate.ts +112 -0
- package/src/commands/dispatch.ts +306 -7
- package/src/commands/editorial-themes.ts +494 -0
- package/src/commands/events.ts +140 -0
- package/src/commands/help.ts +343 -19
- package/src/commands/index.ts +137 -6
- package/src/commands/parse-args.ts +213 -1
- package/src/commands/peers.ts +87 -0
- package/src/commands/search-remote.ts +90 -0
- package/src/commands/series.ts +386 -0
- package/src/commands/similar.ts +97 -0
- package/src/components/intro.ts +9 -0
- package/src/db/editorial-themes.ts +111 -0
- package/src/db/event-index.ts +109 -31
- package/src/db/octagon-cache.ts +2 -1
- package/src/db/schema.ts +23 -0
- package/src/gateway/commands/handler.ts +6 -2
- package/src/scan/octagon-events-api.ts +55 -0
- package/src/scan/octagon-kalshi-api.ts +564 -0
- package/src/scan/octagon-prefetch.ts +48 -27
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `catalysts upcoming [--days N]` — list active Kalshi markets sorted by
|
|
3
|
+
* close_time within the next N days. Group by week so the user can scan an
|
|
4
|
+
* event-calendar view.
|
|
5
|
+
*
|
|
6
|
+
* Pure composition over /kalshi/markets — no new endpoint needed.
|
|
7
|
+
*/
|
|
8
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
9
|
+
import type { CLIResponse } from './json.js';
|
|
10
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
11
|
+
import { fetchUniverse } from './series.js';
|
|
12
|
+
import { formatTable } from './scan-formatters.js';
|
|
13
|
+
|
|
14
|
+
function truncate(s: string, max: number): string {
|
|
15
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmtVol(v: number | null | undefined): string {
|
|
19
|
+
if (v === null || v === undefined) return '-';
|
|
20
|
+
if (v >= 1_000_000) return `${(v / 1_000_000).toFixed(1)}M`;
|
|
21
|
+
if (v >= 1_000) return `${(v / 1_000).toFixed(1)}k`;
|
|
22
|
+
return v.toFixed(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isoWeek(d: Date): string {
|
|
26
|
+
// Return YYYY-MM-DD of the Monday of the ISO week.
|
|
27
|
+
const tmp = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
28
|
+
const day = tmp.getUTCDay() || 7;
|
|
29
|
+
if (day !== 1) tmp.setUTCDate(tmp.getUTCDate() - (day - 1));
|
|
30
|
+
return tmp.toISOString().slice(0, 10);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CatalystRow {
|
|
34
|
+
market_ticker: string;
|
|
35
|
+
series_prefix: string;
|
|
36
|
+
title: string;
|
|
37
|
+
category: string | null;
|
|
38
|
+
close_time: string;
|
|
39
|
+
volume_24h: number | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CatalystsResult {
|
|
43
|
+
days: number;
|
|
44
|
+
weeks: Array<{ week_starting: string; markets: CatalystRow[] }>;
|
|
45
|
+
total: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function handleCatalysts(args: ParsedArgs): Promise<CLIResponse<CatalystsResult>> {
|
|
49
|
+
const sub = args.positionalArgs[0]?.toLowerCase() ?? 'upcoming';
|
|
50
|
+
if (sub !== 'upcoming') {
|
|
51
|
+
return wrapError('catalysts', 'UNKNOWN_SUB', `Unknown subcommand "${sub}". Try: catalysts upcoming [--days N]`);
|
|
52
|
+
}
|
|
53
|
+
const days = args.days ?? 30;
|
|
54
|
+
const now = new Date();
|
|
55
|
+
const cutoff = new Date(now.getTime() + days * 86_400_000);
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const universe = await fetchUniverse({
|
|
59
|
+
close_before: cutoff.toISOString(),
|
|
60
|
+
min_volume_24h: args.minVolume,
|
|
61
|
+
category: args.category,
|
|
62
|
+
});
|
|
63
|
+
// Filter to markets that close in the future window
|
|
64
|
+
const candidates = universe
|
|
65
|
+
.filter((m) => m.close_time && new Date(m.close_time) > now)
|
|
66
|
+
.map<CatalystRow>((m) => ({
|
|
67
|
+
market_ticker: m.market_ticker,
|
|
68
|
+
series_prefix: m.market_ticker.split('-')[0],
|
|
69
|
+
title: m.title,
|
|
70
|
+
category: m.category ?? null,
|
|
71
|
+
close_time: m.close_time as string,
|
|
72
|
+
volume_24h: m.volume_24h ?? null,
|
|
73
|
+
}))
|
|
74
|
+
.sort((a, b) => a.close_time.localeCompare(b.close_time));
|
|
75
|
+
|
|
76
|
+
// Group by ISO week of close_time
|
|
77
|
+
const buckets = new Map<string, CatalystRow[]>();
|
|
78
|
+
for (const c of candidates) {
|
|
79
|
+
const week = isoWeek(new Date(c.close_time));
|
|
80
|
+
const arr = buckets.get(week) ?? [];
|
|
81
|
+
arr.push(c);
|
|
82
|
+
buckets.set(week, arr);
|
|
83
|
+
}
|
|
84
|
+
const limit = args.limit ?? 8;
|
|
85
|
+
const weeks = Array.from(buckets.entries())
|
|
86
|
+
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
87
|
+
.map(([week_starting, markets]) => ({
|
|
88
|
+
week_starting,
|
|
89
|
+
markets: markets
|
|
90
|
+
.sort((a, b) => (b.volume_24h ?? 0) - (a.volume_24h ?? 0))
|
|
91
|
+
.slice(0, limit),
|
|
92
|
+
}));
|
|
93
|
+
return wrapSuccess('catalysts', { days, weeks, total: candidates.length });
|
|
94
|
+
} catch (err) {
|
|
95
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
96
|
+
return wrapError('catalysts', 'OCTAGON_ERROR', message);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatCatalystsHuman(result: CatalystsResult): string {
|
|
101
|
+
const lines: string[] = [];
|
|
102
|
+
lines.push(`Upcoming catalysts — next ${result.days} days, ${result.total} markets across ${result.weeks.length} weeks`);
|
|
103
|
+
lines.push('');
|
|
104
|
+
if (result.weeks.length === 0) {
|
|
105
|
+
lines.push('No markets closing in that window.');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
for (const w of result.weeks) {
|
|
109
|
+
lines.push(`Week of ${w.week_starting} — ${w.markets.length} markets`);
|
|
110
|
+
const rows: string[][] = w.markets.map((m) => [
|
|
111
|
+
m.market_ticker,
|
|
112
|
+
truncate(m.title, 45),
|
|
113
|
+
m.close_time.slice(0, 16).replace('T', ' '),
|
|
114
|
+
fmtVol(m.volume_24h),
|
|
115
|
+
m.category ?? '-',
|
|
116
|
+
]);
|
|
117
|
+
lines.push(formatTable(['Market', 'Title', 'Closes (UTC)', '24h Vol', 'Category'], rows));
|
|
118
|
+
lines.push('');
|
|
119
|
+
}
|
|
120
|
+
return lines.join('\n');
|
|
121
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
2
|
+
import type { CLIResponse } from './json.js';
|
|
3
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
4
|
+
import {
|
|
5
|
+
listClusters,
|
|
6
|
+
listBehavioralClusters,
|
|
7
|
+
getClusterMarkets,
|
|
8
|
+
getBehavioralClusterMarkets,
|
|
9
|
+
getClustersRankedByReturn,
|
|
10
|
+
type ClusterRow,
|
|
11
|
+
type PagedResult,
|
|
12
|
+
type SimilarMarketRow,
|
|
13
|
+
type RankedClustersResponse,
|
|
14
|
+
} from '../scan/octagon-kalshi-api.js';
|
|
15
|
+
import { formatTable } from './scan-formatters.js';
|
|
16
|
+
|
|
17
|
+
function truncate(s: string, max: number): string {
|
|
18
|
+
return s.length > max ? s.slice(0, max - 1) + '…' : s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function fmtPct(v: number): string {
|
|
22
|
+
return `${(v * 100).toFixed(1)}%`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ClustersResult =
|
|
26
|
+
| { kind: 'list'; behavioral: boolean; data: ClusterRow[] }
|
|
27
|
+
| { kind: 'members'; behavioral: boolean; cluster_id: number; markets: PagedResult<SimilarMarketRow> }
|
|
28
|
+
| { kind: 'ranked'; data: RankedClustersResponse };
|
|
29
|
+
|
|
30
|
+
export async function handleClusters(args: ParsedArgs): Promise<CLIResponse<ClustersResult>> {
|
|
31
|
+
try {
|
|
32
|
+
if (args.ranked) {
|
|
33
|
+
const data = await getClustersRankedByReturn({
|
|
34
|
+
timeframe: args.timeframe,
|
|
35
|
+
min_return: args.minReturn,
|
|
36
|
+
top_n_per_cluster: args.topK,
|
|
37
|
+
kind: args.behavioral ? 'behavioral' : 'thematic',
|
|
38
|
+
max_clusters: args.limit,
|
|
39
|
+
});
|
|
40
|
+
return wrapSuccess('clusters', { kind: 'ranked', data });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const positional = args.positionalArgs[0];
|
|
44
|
+
if (positional !== undefined && /^\d+$/.test(positional)) {
|
|
45
|
+
const clusterId = Number(positional);
|
|
46
|
+
const markets = args.behavioral
|
|
47
|
+
? await getBehavioralClusterMarkets(clusterId, { limit: args.limit })
|
|
48
|
+
: await getClusterMarkets(clusterId, { limit: args.limit });
|
|
49
|
+
return wrapSuccess('clusters', { kind: 'members', behavioral: args.behavioral, cluster_id: clusterId, markets });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const params = {
|
|
53
|
+
limit: args.limit,
|
|
54
|
+
sample_titles: 3,
|
|
55
|
+
label_contains: args.labelContains,
|
|
56
|
+
};
|
|
57
|
+
const data = args.behavioral
|
|
58
|
+
? await listBehavioralClusters(params)
|
|
59
|
+
: await listClusters(params);
|
|
60
|
+
return wrapSuccess('clusters', { kind: 'list', behavioral: args.behavioral, data: data.data });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
return wrapError('clusters', 'OCTAGON_ERROR', message);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function formatClustersHuman(result: ClustersResult): string {
|
|
68
|
+
if (result.kind === 'list') return formatClusterList(result.data, result.behavioral);
|
|
69
|
+
if (result.kind === 'members') return formatClusterMembers(result.cluster_id, result.markets, result.behavioral);
|
|
70
|
+
return formatRankedClusters(result.data);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function formatClusterList(clusters: ClusterRow[], behavioral: boolean): string {
|
|
74
|
+
const lines: string[] = [];
|
|
75
|
+
const kindLabel = behavioral ? 'Behavioral' : 'Thematic';
|
|
76
|
+
lines.push(`${kindLabel} clusters — ${clusters.length} result(s)`);
|
|
77
|
+
lines.push('');
|
|
78
|
+
|
|
79
|
+
if (clusters.length === 0) {
|
|
80
|
+
lines.push('No clusters found.');
|
|
81
|
+
return lines.join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const headers = behavioral
|
|
85
|
+
? ['ID', 'Label', 'Size', 'Mean Ret', 'Vol', 'Sample']
|
|
86
|
+
: ['ID', 'Label', 'Size', 'Sample'];
|
|
87
|
+
|
|
88
|
+
const rows: string[][] = clusters.map((c) => {
|
|
89
|
+
const sample = truncate(c.sample_titles[0] ?? '-', 50);
|
|
90
|
+
if (behavioral) {
|
|
91
|
+
return [
|
|
92
|
+
String(c.cluster_id),
|
|
93
|
+
truncate(c.label, 30),
|
|
94
|
+
String(c.size),
|
|
95
|
+
c.mean_daily_return != null ? fmtPct(c.mean_daily_return) : '-',
|
|
96
|
+
c.daily_volatility != null ? fmtPct(c.daily_volatility) : '-',
|
|
97
|
+
sample,
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
return [String(c.cluster_id), truncate(c.label, 30), String(c.size), sample];
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
lines.push(formatTable(headers, rows));
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push(`Use "clusters <id>${behavioral ? ' --behavioral' : ''}" to list members.`);
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function formatClusterMembers(clusterId: number, page: PagedResult<SimilarMarketRow>, behavioral: boolean): string {
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
const kindLabel = behavioral ? 'behavioral' : 'thematic';
|
|
112
|
+
lines.push(`Markets in ${kindLabel} cluster ${clusterId} — ${page.data.length} shown${page.has_more ? ' (more available)' : ''}`);
|
|
113
|
+
lines.push('');
|
|
114
|
+
|
|
115
|
+
if (page.data.length === 0) {
|
|
116
|
+
lines.push('No markets in this cluster.');
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const rows: string[][] = page.data.map((m) => [
|
|
121
|
+
m.market_ticker,
|
|
122
|
+
truncate(m.title, 45),
|
|
123
|
+
m.distance != null ? m.distance.toFixed(3) : '-',
|
|
124
|
+
m.category ?? '-',
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
lines.push(formatTable(['Ticker', 'Title', 'Distance', 'Category'], rows));
|
|
128
|
+
return lines.join('\n');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatRankedClusters(data: RankedClustersResponse): string {
|
|
132
|
+
const lines: string[] = [];
|
|
133
|
+
lines.push(`Clusters ranked by ${data.timeframe} return (${data.kind}, top ${data.top_n_per_cluster} per cluster, min return ${(data.min_return * 100).toFixed(1)}%)`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
|
|
136
|
+
if (data.data.length === 0) {
|
|
137
|
+
lines.push('No clusters meet the threshold.');
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rows: string[][] = data.data.map((c) => [
|
|
142
|
+
String(c.cluster_id),
|
|
143
|
+
truncate(c.label, 30),
|
|
144
|
+
String(c.size),
|
|
145
|
+
fmtPct(c.summary.total_return),
|
|
146
|
+
c.summary.sharpe != null ? c.summary.sharpe.toFixed(2) : '-',
|
|
147
|
+
fmtPct(c.summary.max_drawdown),
|
|
148
|
+
fmtPct(c.summary.win_rate),
|
|
149
|
+
]);
|
|
150
|
+
|
|
151
|
+
lines.push(formatTable(['ID', 'Label', 'Size', 'Total Ret', 'Sharpe', 'Max DD', 'Win%'], rows));
|
|
152
|
+
return lines.join('\n');
|
|
153
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { wrapSuccess, wrapError } from './json.js';
|
|
2
|
+
import type { CLIResponse } from './json.js';
|
|
3
|
+
import type { ParsedArgs } from './parse-args.js';
|
|
4
|
+
import { getCorrelations, type CorrelationResponseWithSides } from '../scan/octagon-kalshi-api.js';
|
|
5
|
+
import { formatTable } from './scan-formatters.js';
|
|
6
|
+
|
|
7
|
+
function shortTicker(t: string, max = 18): string {
|
|
8
|
+
return t.length > max ? `${t.slice(0, max - 1)}…` : t;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function collectTickers(args: ParsedArgs): string[] {
|
|
12
|
+
const set = new Set<string>();
|
|
13
|
+
for (const p of args.positionalArgs) {
|
|
14
|
+
const upper = p.toUpperCase();
|
|
15
|
+
if (upper) set.add(upper);
|
|
16
|
+
}
|
|
17
|
+
if (args.tickers) {
|
|
18
|
+
for (const t of args.tickers.split(',')) {
|
|
19
|
+
const upper = t.trim().toUpperCase();
|
|
20
|
+
if (upper) set.add(upper);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return Array.from(set);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function handleCorrelate(args: ParsedArgs): Promise<CLIResponse<CorrelationResponseWithSides>> {
|
|
27
|
+
const tickers = collectTickers(args);
|
|
28
|
+
if (tickers.length < 2) {
|
|
29
|
+
return wrapError(
|
|
30
|
+
'correlate',
|
|
31
|
+
'TOO_FEW_TICKERS',
|
|
32
|
+
'Usage: correlate <ticker1> <ticker2> [...] [--window-days N] [--sides yes,no,yes] [--cells]',
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
if (tickers.length > 100) {
|
|
36
|
+
return wrapError('correlate', 'TOO_MANY_TICKERS', 'At most 100 tickers allowed.');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --sides yes,no,yes — must match ticker count (server enforces this too)
|
|
40
|
+
let sides: ('yes' | 'no')[] | undefined;
|
|
41
|
+
if (args.sides) {
|
|
42
|
+
sides = args.sides.split(',').map((s) => (s.trim().toLowerCase() === 'no' ? 'no' : 'yes'));
|
|
43
|
+
if (sides.length !== tickers.length) {
|
|
44
|
+
return wrapError('correlate', 'SIDES_MISMATCH',
|
|
45
|
+
`--sides has ${sides.length} entries but ${tickers.length} tickers were supplied.`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const data = await getCorrelations({
|
|
51
|
+
market_tickers: tickers,
|
|
52
|
+
sides,
|
|
53
|
+
include_cell_detail: args.cells || undefined,
|
|
54
|
+
window_days: args.windowDays,
|
|
55
|
+
interval: args.correlationInterval,
|
|
56
|
+
});
|
|
57
|
+
return wrapSuccess('correlate', data);
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
60
|
+
return wrapError('correlate', 'OCTAGON_ERROR', message);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function formatCorrelationHuman(data: CorrelationResponseWithSides): string {
|
|
65
|
+
const lines: string[] = [];
|
|
66
|
+
const sidesLabel = data.sides ? ` (sides: ${data.sides.join(',')})` : '';
|
|
67
|
+
lines.push(`Correlation matrix — ${data.tickers.length} markets${sidesLabel}, ${data.window_days}d window, interval ${data.interval}`);
|
|
68
|
+
if (data.missing.length > 0) {
|
|
69
|
+
lines.push(` Dropped (no candle data): ${data.missing.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
lines.push('');
|
|
72
|
+
|
|
73
|
+
if (data.tickers.length === 0) {
|
|
74
|
+
lines.push('No matrix available — all tickers were missing candle data.');
|
|
75
|
+
return lines.join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Matrix table — header row of short tickers, body of correlation values.
|
|
79
|
+
const headerRow: string[][] = [['', ...data.tickers.map((t) => shortTicker(t, 14))]];
|
|
80
|
+
const bodyRows: string[][] = data.matrix.map((row, i) => [
|
|
81
|
+
shortTicker(data.tickers[i] ?? '?', 14),
|
|
82
|
+
...row.map((v) => (v == null ? '-' : v.toFixed(2))),
|
|
83
|
+
]);
|
|
84
|
+
lines.push(formatTable(headerRow[0], bodyRows));
|
|
85
|
+
|
|
86
|
+
if (data.ranked_pairs.length > 0) {
|
|
87
|
+
lines.push('');
|
|
88
|
+
lines.push('Most-uncorrelated pairs (ascending):');
|
|
89
|
+
const topN = data.ranked_pairs.slice(0, 10);
|
|
90
|
+
const rows: string[][] = topN.map((p) => [
|
|
91
|
+
shortTicker(p.ticker_a, 22),
|
|
92
|
+
shortTicker(p.ticker_b, 22),
|
|
93
|
+
p.correlation.toFixed(3),
|
|
94
|
+
]);
|
|
95
|
+
lines.push(formatTable(['Ticker A', 'Ticker B', 'Corr'], rows));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (data.cells_detail && data.cells_detail.length > 0) {
|
|
99
|
+
lines.push('');
|
|
100
|
+
lines.push('Cell detail:');
|
|
101
|
+
const rows: string[][] = data.cells_detail.map((c) => [
|
|
102
|
+
shortTicker(c.ticker_a, 20),
|
|
103
|
+
shortTicker(c.ticker_b, 20),
|
|
104
|
+
c.correlation == null ? '-' : c.correlation.toFixed(3),
|
|
105
|
+
String(c.overlap_count),
|
|
106
|
+
c.reason,
|
|
107
|
+
]);
|
|
108
|
+
lines.push(formatTable(['Ticker A', 'Ticker B', 'Corr', 'Overlap', 'Reason'], rows));
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return lines.join('\n');
|
|
112
|
+
}
|