@hoox-sh/pynets 0.2.0 → 0.4.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.
@@ -9,6 +9,7 @@
9
9
  import { timeframeMinutes } from "./request.ts";
10
10
 
11
11
  const SECONDS_PER_MINUTE = 60;
12
+ const SECONDS_PER_HOUR = 3600;
12
13
  const SECONDS_PER_DAY = 86_400;
13
14
  const SECONDS_PER_WEEK = 604_800;
14
15
  const SECONDS_PER_MONTH = 2_592_000; // 30-day month
@@ -71,6 +72,39 @@ export function timeframeIsMonthly(period: string | null): boolean {
71
72
  return p === "M" || p === "1M" || p === "MO" || p === "MONTH" || p === "MONTHS" || /^\d+MO$/.test(p);
72
73
  }
73
74
 
75
+ /** Pine `timeframe.isseconds` / `isinseconds` — `"1S"` / `"15S"`. */
76
+ export function timeframeIsSeconds(period: string | null): boolean {
77
+ const p = norm(period);
78
+ if (p == null) return false;
79
+ return p.endsWith("S") && /^\d+$/.test(p.slice(0, -1));
80
+ }
81
+
82
+ /**
83
+ * Pine `timeframe.isminutes`. Numeric `"1"`/`"5"` (not `"60"` hours);
84
+ * `"15M"` minutes, not monthly `"1M"` / `"M"`.
85
+ */
86
+ export function timeframeIsMinutes(period: string | null): boolean {
87
+ if (timeframeIsHours(period) || timeframeIsDwm(period) || timeframeIsSeconds(period)) return false;
88
+ const p = norm(period);
89
+ if (p == null) return false;
90
+ if (/^\d+$/.test(p)) return true;
91
+ return p.endsWith("M") && /^\d+$/.test(p.slice(0, -1)) && p !== "1M";
92
+ }
93
+
94
+ /** Pine `timeframe.ishours` — `"1H"` / numeric minutes ≥ 60. */
95
+ export function timeframeIsHours(period: string | null): boolean {
96
+ if (timeframeIsDaily(period) || timeframeIsWeekly(period) || timeframeIsMonthly(period)) return false;
97
+ const p = norm(period);
98
+ if (p == null) return false;
99
+ if (p.endsWith("H")) return true;
100
+ return /^\d+$/.test(p) && Number(p) >= 60;
101
+ }
102
+
103
+ /** Pine `timeframe.isdwm` — daily / weekly / monthly. */
104
+ export function timeframeIsDwm(period: string | null): boolean {
105
+ return timeframeIsDaily(period) || timeframeIsWeekly(period) || timeframeIsMonthly(period);
106
+ }
107
+
74
108
  /** Leading integer, or 1 when missing / empty / na. */
75
109
  export function timeframeMultiplier(period: string | null): number {
76
110
  if (period == null || period === "") return 1;
@@ -83,3 +117,168 @@ function norm(period: string | null): string | null {
83
117
  const p = period.trim().toUpperCase();
84
118
  return p === "" ? null : p;
85
119
  }
120
+
121
+ /** Bare `D`/`W`/`M` plus `1D`/`1W`/`1M` only. Case-sensitive — `"d"` is a fixed bucket. */
122
+ const CALENDAR_TFS = new Set(["D", "W", "M", "1D", "1W", "1M"]);
123
+
124
+ const PERIOD_SHORTCUTS: Record<string, number> = {
125
+ "1H": SECONDS_PER_HOUR,
126
+ H: SECONDS_PER_HOUR,
127
+ D: SECONDS_PER_DAY,
128
+ W: SECONDS_PER_WEEK,
129
+ MO: SECONDS_PER_MONTH,
130
+ M: SECONDS_PER_MONTH,
131
+ "1M": SECONDS_PER_MONTH,
132
+ MONTH: SECONDS_PER_MONTH,
133
+ MONTHS: SECONDS_PER_MONTH,
134
+ };
135
+
136
+ /** Python `TIMEFRAME_SUFFIXES` insertion order (`M` before `MO`). */
137
+ const PERIOD_SUFFIXES: Array<[string, number]> = [
138
+ ["M", SECONDS_PER_MONTH],
139
+ ["H", SECONDS_PER_HOUR],
140
+ ["D", SECONDS_PER_DAY],
141
+ ["W", SECONDS_PER_WEEK],
142
+ ["MO", SECONDS_PER_MONTH],
143
+ ];
144
+
145
+ const UTC_ZONE_NAMES = new Set(["UTC", "utc", "Etc/UTC", "GMT", "gmt", "syminfo.timezone"]);
146
+ const OFFSET_RE = /^(?:(?:UTC|GMT)\s*)?([+-])\s*(\d{1,2})(?::(\d{2})|(\d{2}))?$/i;
147
+
148
+ type ChangeZone = { off: number } | { iana: string };
149
+
150
+ const zoneFmt = new Map<string, Intl.DateTimeFormat | null>();
151
+
152
+ /**
153
+ * `timeframe.change` / `timeframe_period_changed`.
154
+ * Bar 0 is a new period. Missing time, NaN, or an unusable tf → false.
155
+ * Calendar frames use *tz* (unset / unknown → UTC). Everything else is a
156
+ * fixed-width UTC bucket from `timeframe_in_seconds`.
157
+ */
158
+ export function timeframePeriodChanged(
159
+ currTs: unknown,
160
+ prevTs: unknown,
161
+ timeframeStr: string | null | undefined,
162
+ barIndex?: number | null,
163
+ tz?: unknown,
164
+ ): boolean {
165
+ const currId = periodId(currTs, timeframeStr, tz);
166
+ if (currId == null) return false;
167
+ if (barIndex != null) {
168
+ if (barIndex <= 0) return true;
169
+ if (prevTs == null) return false;
170
+ } else if (prevTs == null) {
171
+ return true;
172
+ }
173
+ const prevId = periodId(prevTs, timeframeStr, tz);
174
+ if (prevId == null) return false;
175
+ return currId !== prevId;
176
+ }
177
+
178
+ function periodId(ts: unknown, timeframeStr: string | null | undefined, tz: unknown): number | null {
179
+ if (timeframeStr == null) return null;
180
+ const raw = String(timeframeStr).trim();
181
+ if (raw === "") return null;
182
+ const ms = normalizeTimeMs(ts);
183
+ if (ms == null) return null;
184
+ if (CALENDAR_TFS.has(raw)) return calendarId(ms, raw, tz);
185
+ const sec = periodSeconds(raw);
186
+ if (sec == null || sec <= 0) return null;
187
+ return Math.floor(ms / (sec * 1000));
188
+ }
189
+
190
+ /** Python `timeframe_in_seconds` (not `timeframeMinutes` — `"15M"` is 15 months). */
191
+ function periodSeconds(raw: string): number | null {
192
+ const tf = raw.trim().toUpperCase();
193
+ if (tf === "") return null;
194
+ const shortcut = PERIOD_SHORTCUTS[tf];
195
+ if (shortcut != null) return shortcut;
196
+ if (tf.endsWith("M") && /^\d+$/.test(tf.slice(0, -1))) {
197
+ return Number(tf.slice(0, -1)) * SECONDS_PER_MONTH;
198
+ }
199
+ if (/^\d+$/.test(tf)) return Number(tf) * SECONDS_PER_MINUTE;
200
+ for (const [suffix, mult] of PERIOD_SUFFIXES) {
201
+ if (!tf.endsWith(suffix)) continue;
202
+ const head = tf.slice(0, -suffix.length);
203
+ if (head !== "" && /^\d+$/.test(head)) return Number(head) * mult;
204
+ }
205
+ return null;
206
+ }
207
+
208
+ function calendarId(ms: number, raw: string, tz: unknown): number | null {
209
+ const kind = raw[raw.length - 1];
210
+ const civil = civilInZone(ms, resolveZone(tz));
211
+ if (civil == null) return null;
212
+ if (kind === "D") return civil.year * 10_000 + civil.month * 100 + civil.day;
213
+ if (kind === "W") return isoWeekId(civil.year, civil.month, civil.day);
214
+ return civil.year * 12 + civil.month;
215
+ }
216
+
217
+ function normalizeTimeMs(ts: unknown): number | null {
218
+ if (ts == null) return null;
219
+ const t = typeof ts === "number" ? ts : typeof ts === "string" ? Number(ts) : NaN;
220
+ if (!Number.isFinite(t)) return null;
221
+ return t < 1e11 ? t * 1000 : t;
222
+ }
223
+
224
+ function resolveZone(tz: unknown): ChangeZone {
225
+ if (typeof tz !== "string") return { off: 0 };
226
+ const s = tz.trim();
227
+ if (s === "" || UTC_ZONE_NAMES.has(s)) return { off: 0 };
228
+ const m = OFFSET_RE.exec(s);
229
+ if (m) {
230
+ const sign = m[1] === "+" ? 1 : -1;
231
+ const hours = Number(m[2]);
232
+ const mins = Number(m[3] || m[4] || 0);
233
+ const off = sign * (hours * 60 + mins) * 60_000;
234
+ if (!Number.isFinite(off) || Math.abs(off) >= 86_400_000) return { off: 0 };
235
+ return { off };
236
+ }
237
+ return { iana: s };
238
+ }
239
+
240
+ function civilInZone(ms: number, zone: ChangeZone): { year: number; month: number; day: number } | null {
241
+ if (!Number.isFinite(ms)) return null;
242
+ if ("off" in zone) return utcCivil(ms + zone.off);
243
+ let fmt = zoneFmt.get(zone.iana);
244
+ if (fmt === undefined) {
245
+ try {
246
+ fmt = new Intl.DateTimeFormat("en-US", {
247
+ timeZone: zone.iana,
248
+ year: "numeric",
249
+ month: "2-digit",
250
+ day: "2-digit",
251
+ });
252
+ } catch {
253
+ fmt = null;
254
+ }
255
+ zoneFmt.set(zone.iana, fmt);
256
+ }
257
+ if (fmt == null) return utcCivil(ms);
258
+ let year = NaN;
259
+ let month = NaN;
260
+ let day = NaN;
261
+ for (const p of fmt.formatToParts(new Date(ms))) {
262
+ if (p.type === "year") year = Number(p.value);
263
+ else if (p.type === "month") month = Number(p.value);
264
+ else if (p.type === "day") day = Number(p.value);
265
+ }
266
+ if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) return utcCivil(ms);
267
+ return { year, month, day };
268
+ }
269
+
270
+ function utcCivil(ms: number): { year: number; month: number; day: number } {
271
+ const d = new Date(ms);
272
+ return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
273
+ }
274
+
275
+ /** ISO year*100+week of a civil date (Monday start), matching `datetime.isocalendar`. */
276
+ function isoWeekId(year: number, month: number, day: number): number {
277
+ const date = new Date(Date.UTC(year, month - 1, day));
278
+ const dayNr = date.getUTCDay() || 7;
279
+ date.setUTCDate(date.getUTCDate() + 4 - dayNr);
280
+ const isoYear = date.getUTCFullYear();
281
+ const diffDays = Math.round((date.getTime() - Date.UTC(isoYear, 0, 1)) / 86_400_000);
282
+ const week = Math.ceil((diffDays + 1) / 7);
283
+ return isoYear * 100 + week;
284
+ }