Count short tape holes in source index so 1m lines stay straight.
Prod already owned the empty 1m slots; source index still treated the bars on either side as adjacent. Also post a geometry SNAPDBG on ?diag=1.
This commit is contained in:
parent
c28b0ce535
commit
f5ba154c98
10 changed files with 259 additions and 14 deletions
|
|
@ -89,8 +89,11 @@ docker compose logs api | grep SNAPDBG
|
||||||
With it on, every snap the trendline tool computes is posted to
|
With it on, every snap the trendline tool computes is posted to
|
||||||
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
|
`/api/debug/snap` and logged server-side — the cursor's time, price and x, the
|
||||||
snapped time and price, how many bars were held, the first and last bar, and the
|
snapped time and price, how many bars were held, the first and last bar, and the
|
||||||
chart's width. Throttled to about one a second. It reads the client's own
|
chart's width. After bars and levels load it also posts one `kind=geometry`
|
||||||
numbers, which is exactly what "works in my headless run" cannot tell you.
|
report: 1m holes, whether `futureSpace` owns them, and each visible manual
|
||||||
|
line's off-median screen segments. Throttled to about one a second. It reads
|
||||||
|
the client's own numbers, which is exactly what "works in my headless run"
|
||||||
|
cannot tell you. Do not ask the user to paste that from the console.
|
||||||
|
|
||||||
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
Extend it when the next geometry puzzle appears; the endpoint takes whatever
|
||||||
fields `SnapReport` declares.
|
fields `SnapReport` declares.
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,32 @@ from bisect import bisect_right
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.bars.session import bucket_duration, next_bucket_start
|
from app.bars.session import bucket_duration, next_bucket_start
|
||||||
|
|
||||||
|
# Same bound as ConfluenceChart.MAX_INTRADAY_GAP_SECONDS: short tape holes
|
||||||
|
# occupy empty slots on the chart, so source index must count them too.
|
||||||
|
MAX_INTRADAY_GAP_SECONDS = 30 * 60
|
||||||
|
|
||||||
|
|
||||||
|
def fill_short_gaps(times: list[int], tf: Timeframe) -> list[int]:
|
||||||
|
"""Insert missing bucket opens inside short intraday holes.
|
||||||
|
|
||||||
|
A 9-minute 1m hole is eight empty columns on screen. Without these times,
|
||||||
|
source index treats the two surrounding bars as adjacent and the line
|
||||||
|
goes flat across the hole. Settlement and weekends stay compressed.
|
||||||
|
"""
|
||||||
|
if tf is Timeframe.D1 or len(times) < 2:
|
||||||
|
return times
|
||||||
|
step = tf.seconds
|
||||||
|
if step <= 0:
|
||||||
|
return times
|
||||||
|
filled = [times[0]]
|
||||||
|
for time in times[1:]:
|
||||||
|
prev = filled[-1]
|
||||||
|
gap = time - prev
|
||||||
|
if step < gap <= MAX_INTRADAY_GAP_SECONDS:
|
||||||
|
filled.extend(range(prev + step, time, step))
|
||||||
|
filled.append(time)
|
||||||
|
return filled
|
||||||
|
|
||||||
|
|
||||||
def index_at(times: list[int], t: int) -> float:
|
def index_at(times: list[int], t: int) -> float:
|
||||||
"""Fractional index of a timestamp within an ascending bar-time series."""
|
"""Fractional index of a timestamp within an ascending bar-time series."""
|
||||||
|
|
@ -91,6 +117,7 @@ def price_in_timeframe_space(
|
||||||
# Endpoints may deliberately sit in the projection area. They use the same
|
# Endpoints may deliberately sit in the projection area. They use the same
|
||||||
# repeated-source-bucket approximation as the browser; the live evaluation
|
# repeated-source-bucket approximation as the browser; the live evaluation
|
||||||
# instant itself must still belong to held source history.
|
# instant itself must still belong to held source history.
|
||||||
|
times = fill_short_gaps(times, tf)
|
||||||
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
|
start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True)
|
||||||
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
|
end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True)
|
||||||
target_index = timeframe_index_at(times, t, tf)
|
target_index = timeframe_index_at(times, t, tf)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
from app.config import DEFAULT_MAX_BARS_PER_TF
|
from app.config import DEFAULT_MAX_BARS_PER_TF
|
||||||
|
|
@ -395,6 +395,7 @@ def create_comment(request: Request, payload: CommentCreate):
|
||||||
|
|
||||||
class SnapReport(BaseModel):
|
class SnapReport(BaseModel):
|
||||||
"""What the browser computed for one snap, for diagnosing chart geometry."""
|
"""What the browser computed for one snap, for diagnosing chart geometry."""
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
cursor_t: int | None = None
|
cursor_t: int | None = None
|
||||||
cursor_p: float | None = None
|
cursor_p: float | None = None
|
||||||
cursor_x: float | None = None
|
cursor_x: float | None = None
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
from app.api.deps import SESSION_COOKIE, session_matches, token_matches
|
from app.api.deps import SESSION_COOKIE, session_matches, token_matches
|
||||||
from app.analysis.levels import LevelKind
|
from app.analysis.levels import LevelKind
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
from app.analysis.bar_space import fill_short_gaps
|
||||||
from app.bars.session import bucket_duration, future_bucket_starts
|
from app.bars.session import bucket_duration, future_bucket_starts
|
||||||
from app.analysis.confluence import cluster_levels
|
from app.analysis.confluence import cluster_levels
|
||||||
|
|
||||||
|
|
@ -70,7 +71,7 @@ def trendline_geometry(runtime) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def trendline_series(runtime, tf: Timeframe) -> dict:
|
def trendline_series(runtime, tf: Timeframe) -> dict:
|
||||||
times = [bar.t for bar in runtime.store.get(tf)]
|
times = fill_short_gaps([bar.t for bar in runtime.store.get(tf)], tf)
|
||||||
value = {"times": times}
|
value = {"times": times}
|
||||||
if tf is Timeframe.D1:
|
if tf is Timeframe.D1:
|
||||||
value["durations"] = [bucket_duration(t, tf) for t in times]
|
value["durations"] = [bucket_duration(t, tf) for t in times]
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,9 @@ decisions behind them in `docs/plan.md`.
|
||||||
|
|
||||||
### Revisit the trendline live-edge bend with painted-pixel evidence
|
### Revisit the trendline live-edge bend with painted-pixel evidence
|
||||||
|
|
||||||
Production still appears to show a bend where 30m manual lines cross from the
|
Mid-session 1m tape holes are now counted in source index as well as on the
|
||||||
last real candle into future whitespace. The deployed code is verified to use
|
time scale. Production still appears to show a bend where 30m manual lines
|
||||||
|
cross from the last real candle into future whitespace. The deployed code is verified to use
|
||||||
one canvas `LineSeries`, retained transparent time-scale points, and canonical
|
one canvas `LineSeries`, retained transparent time-scale points, and canonical
|
||||||
source-space prices. Diagnostic values can report unequal historical/future
|
source-space prices. Diagnostic values can report unequal historical/future
|
||||||
screen slopes when the final candles are sparse, but synthetic browser coverage
|
screen slopes when the final candles are sparse, but synthetic browser coverage
|
||||||
|
|
|
||||||
|
|
@ -1336,3 +1336,19 @@ alert before the deferred global rebuild.
|
||||||
Finally, source-dependent shift operations no longer silently keep the old time
|
Finally, source-dependent shift operations no longer silently keep the old time
|
||||||
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
|
or fall back to displayed geometry beyond the supplied horizon. Drag, duplicate,
|
||||||
and nudge abort cleanly when the canonical shift cannot be represented.
|
and nudge abort cleanly when the canonical shift cannot be represented.
|
||||||
|
|
||||||
|
### 2026-08-26 — prod 1m holes were slotted, source index was not
|
||||||
|
|
||||||
|
A production 1m dump showed 23 holes. Every 1–10 minute tape hole already owned
|
||||||
|
its empty `futureSpace` slots, including the Yahoo→Schwab join at 02:18–02:27
|
||||||
|
CDT. Settlement (15:59–17:00) and the weekend stay compressed on purpose.
|
||||||
|
|
||||||
|
The remaining bend is source index: a 1m/5m line still treated the bars on
|
||||||
|
either side of a slotted hole as adjacent, so nine screen slots carried one
|
||||||
|
minute of price. `fill_short_gaps` now inserts the missing bucket opens (same
|
||||||
|
30-minute bound as the display) in trendline geometry and in
|
||||||
|
`price_in_timeframe_space`. Weekends and settlement are unchanged.
|
||||||
|
|
||||||
|
`?diag=1` also posts one `kind=geometry` SNAPDBG after bars load — gap ownership
|
||||||
|
and off-median line segments — so the next check is `logs --since 20m`, not
|
||||||
|
another console paste.
|
||||||
|
|
|
||||||
117
static/chart.js
117
static/chart.js
|
|
@ -683,6 +683,7 @@ class ConfluenceChart {
|
||||||
}
|
}
|
||||||
this.syncRthLines();
|
this.syncRthLines();
|
||||||
this.scheduleOverlays(true);
|
this.scheduleOverlays(true);
|
||||||
|
this.scheduleGeometryDiagnostic();
|
||||||
}
|
}
|
||||||
|
|
||||||
setTrendlineGeometry(geometry) {
|
setTrendlineGeometry(geometry) {
|
||||||
|
|
@ -750,6 +751,7 @@ class ConfluenceChart {
|
||||||
source.futureTimes = futureTimes.map(Number);
|
source.futureTimes = futureTimes.map(Number);
|
||||||
source.futureDurations = futureDurations.map(Number);
|
source.futureDurations = futureDurations.map(Number);
|
||||||
source.allTimes = [...source.times, ...source.futureTimes];
|
source.allTimes = [...source.times, ...source.futureTimes];
|
||||||
|
source._filled = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1441,6 +1443,7 @@ class ConfluenceChart {
|
||||||
this.pendingView = null;
|
this.pendingView = null;
|
||||||
}
|
}
|
||||||
this.scheduleOverlays(true);
|
this.scheduleOverlays(true);
|
||||||
|
this.scheduleGeometryDiagnostic();
|
||||||
}
|
}
|
||||||
|
|
||||||
renderContextLabels() {
|
renderContextLabels() {
|
||||||
|
|
@ -1738,6 +1741,90 @@ class ConfluenceChart {
|
||||||
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
if (this.pendingAnchor) this.renderPending(this.pendingAnchor, this.snapPoint(point));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
postDebugSnap(body) {
|
||||||
|
fetch('/api/debug/snap', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json',
|
||||||
|
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleGeometryDiagnostic() {
|
||||||
|
if (!ConfluenceChart.diagnosticMode()) return;
|
||||||
|
clearTimeout(this._geometryDiagTimer);
|
||||||
|
this._geometryDiagTimer = setTimeout(() => this.reportGeometryDiagnostic(), 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
reportGeometryDiagnostic() {
|
||||||
|
if (!ConfluenceChart.diagnosticMode() || !this.bars.length) return;
|
||||||
|
const now = Date.now();
|
||||||
|
if (this._lastGeometryReport && now - this._lastGeometryReport < 2000) return;
|
||||||
|
this._lastGeometryReport = now;
|
||||||
|
const step = ConfluenceChart.barInterval(this.bars) || 60;
|
||||||
|
const space = new Set((this.futureSpace?.data() || []).map(point => Number(point.time)));
|
||||||
|
const gaps = [];
|
||||||
|
for (let index = 1; index < this.bars.length; index += 1) {
|
||||||
|
const delta = this.bars[index].t - this.bars[index - 1].t;
|
||||||
|
if (delta <= step) continue;
|
||||||
|
let owned = 0;
|
||||||
|
for (let time = this.bars[index - 1].t + step; time < this.bars[index].t; time += step) {
|
||||||
|
if (space.has(time)) owned += 1;
|
||||||
|
}
|
||||||
|
gaps.push({
|
||||||
|
from: this.bars[index - 1].t,
|
||||||
|
to: this.bars[index].t,
|
||||||
|
missing: delta / step - 1,
|
||||||
|
owned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const lines = this.levels
|
||||||
|
.filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level))
|
||||||
|
.slice(0, 24)
|
||||||
|
.map(level => {
|
||||||
|
const pts = this.levelSeries.get(level.id)?.series.data() || [];
|
||||||
|
const segs = [];
|
||||||
|
for (let index = 1; index < pts.length; index += 1) {
|
||||||
|
const first = pts[index - 1];
|
||||||
|
const second = pts[index];
|
||||||
|
const x1 = this.chart.timeScale().timeToCoordinate(first.time);
|
||||||
|
const x2 = this.chart.timeScale().timeToCoordinate(second.time);
|
||||||
|
const y1 = this.candles.priceToCoordinate(first.value);
|
||||||
|
const y2 = this.candles.priceToCoordinate(second.value);
|
||||||
|
if ([x1, x2, y1, y2].some(value => value == null) || x2 === x1) continue;
|
||||||
|
segs.push({
|
||||||
|
t1: Number(first.time),
|
||||||
|
t2: Number(second.time),
|
||||||
|
dtMin: (Number(second.time) - Number(first.time)) / 60,
|
||||||
|
dx: +(x2 - x1).toFixed(2),
|
||||||
|
slope: +((y2 - y1) / (x2 - x1)).toFixed(4),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const slopes = segs.map(seg => seg.slope).sort((a, b) => a - b);
|
||||||
|
const med = slopes.length ? slopes[Math.floor(slopes.length / 2)] : null;
|
||||||
|
const thresh = Math.abs(med || 1) * 0.08;
|
||||||
|
return {
|
||||||
|
n: level.number,
|
||||||
|
tf: level.tf,
|
||||||
|
pts: pts.length,
|
||||||
|
med,
|
||||||
|
bad: segs.filter(seg => med != null && Math.abs(seg.slope - med) > thresh).slice(0, 12),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.postDebugSnap({
|
||||||
|
kind: 'geometry',
|
||||||
|
tf: this.bars[0].tf,
|
||||||
|
bars_held: this.bars.length,
|
||||||
|
first_bar_t: this.bars[0].t,
|
||||||
|
last_bar_t: this.bars[this.bars.length - 1].t,
|
||||||
|
chart_w: this.chartEl?.clientWidth,
|
||||||
|
chart_h: this.chartEl?.clientHeight,
|
||||||
|
note: `gaps ${gaps.length} lines ${lines.length}`,
|
||||||
|
gaps: gaps.slice(0, 40),
|
||||||
|
lines,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
showSnapDot(snapped, point = {}) {
|
showSnapDot(snapped, point = {}) {
|
||||||
if (snapped.t == null) return this.hideSnapDot();
|
if (snapped.t == null) return this.hideSnapDot();
|
||||||
const x = this.coordinateAtTime(snapped.t);
|
const x = this.coordinateAtTime(snapped.t);
|
||||||
|
|
@ -1789,12 +1876,7 @@ class ConfluenceChart {
|
||||||
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
: `drift ${drift}s (${Math.round(drift / step)} bars), step ${step}s`,
|
||||||
};
|
};
|
||||||
this.lastSnapDiagnostic = body;
|
this.lastSnapDiagnostic = body;
|
||||||
fetch('/api/debug/snap', {
|
this.postDebugSnap(body);
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json',
|
|
||||||
...(localStorage.getItem('chart-token') ? { 'X-Chart-Token': localStorage.getItem('chart-token') } : {}) },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2396,8 +2478,29 @@ class ConfluenceChart {
|
||||||
?? source.futureDurations[source.futureDurations.length - 1] ?? null;
|
?? source.futureDurations[source.futureDurations.length - 1] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static fillShortGaps(source) {
|
||||||
|
const times = source?.allTimes || source?.times || [];
|
||||||
|
const step = source?.duration;
|
||||||
|
if (!(step > 0) || times.length < 2) return times;
|
||||||
|
const filled = [times[0]];
|
||||||
|
for (let index = 1; index < times.length; index += 1) {
|
||||||
|
const prev = filled[filled.length - 1];
|
||||||
|
const next = times[index];
|
||||||
|
const gap = next - prev;
|
||||||
|
if (gap > step && gap <= ConfluenceChart.MAX_INTRADAY_GAP_SECONDS) {
|
||||||
|
for (let time = prev + step; time < next; time += step) filled.push(time);
|
||||||
|
}
|
||||||
|
filled.push(next);
|
||||||
|
}
|
||||||
|
return filled;
|
||||||
|
}
|
||||||
|
|
||||||
static sourceTimes(source) {
|
static sourceTimes(source) {
|
||||||
return source?.allTimes || source?.times || [];
|
if (!source) return [];
|
||||||
|
if (source._filled) return source._filled;
|
||||||
|
const filled = ConfluenceChart.fillShortGaps(source);
|
||||||
|
source._filled = filled;
|
||||||
|
return filled;
|
||||||
}
|
}
|
||||||
|
|
||||||
static timeframeIndexAt(source, time, allowFuture = true) {
|
static timeframeIndexAt(source, time, allowFuture = true) {
|
||||||
|
|
|
||||||
|
|
@ -410,6 +410,64 @@ test('a 30m line stays straight on 1m when the bucket is missing its first minut
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a 1m line stays straight across a slotted mid-session hole',
|
||||||
|
{ timeout: 180000 }, async () => {
|
||||||
|
await withChart(async page => {
|
||||||
|
const result = await page.evaluate(() => {
|
||||||
|
const c = window.__chart;
|
||||||
|
const minute = 60;
|
||||||
|
const start = 500000;
|
||||||
|
const bars = [];
|
||||||
|
for (let t = start; t < start + 20 * minute; t += minute) {
|
||||||
|
bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true });
|
||||||
|
}
|
||||||
|
for (let t = start + 29 * minute; t < start + 50 * minute; t += minute) {
|
||||||
|
bars.push({ tf: '1m', t, o: 100, h: 101, l: 99, c: 100, v: 1, closed: true });
|
||||||
|
}
|
||||||
|
const line = {
|
||||||
|
id: '1m-hole', kind: 'manual', tf: '1m', side: 'support', label: 'hole',
|
||||||
|
anchor_t: start, anchor_p: 100, last_t: start + 10 * minute,
|
||||||
|
slope: 1 / minute, cutoff_t: null, hidden: false, color: '#65b7cf',
|
||||||
|
line_width: 2, provisional: false,
|
||||||
|
};
|
||||||
|
c.setDisplayFutureTimes(Array.from(
|
||||||
|
{ length: ConfluenceChart.FUTURE_SLOTS }, (_, index) => bars.at(-1).t + (index + 1) * minute,
|
||||||
|
));
|
||||||
|
c.setBars(bars);
|
||||||
|
c.setTrendlineGeometry({
|
||||||
|
mode: 'source_tf',
|
||||||
|
series: { '1m': { times: bars.map(bar => bar.t), duration: minute } },
|
||||||
|
});
|
||||||
|
c.syncLevels([line]);
|
||||||
|
const data = c.levelSeries.get(line.id).series.data();
|
||||||
|
const point = time => {
|
||||||
|
const row = data.find(value => Number(value.time) === time);
|
||||||
|
return {
|
||||||
|
x: c.chart.timeScale().timeToCoordinate(time),
|
||||||
|
y: row ? c.candles.priceToCoordinate(row.value) : null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const a = point(start + 18 * minute);
|
||||||
|
const b = point(start + 19 * minute);
|
||||||
|
const d = point(start + 29 * minute);
|
||||||
|
const e = point(start + 30 * minute);
|
||||||
|
return {
|
||||||
|
before: (b.y - a.y) / (b.x - a.x),
|
||||||
|
across: (d.y - b.y) / (d.x - b.x),
|
||||||
|
after: (e.y - d.y) / (e.x - d.x),
|
||||||
|
filled: ConfluenceChart.sourceTimes(c.trendlineSourceSeries.get('1m'))
|
||||||
|
.includes(start + 20 * minute),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
assert.equal(result.filled, true, 'short 1m hole was not inserted into source times');
|
||||||
|
assert.ok(Math.abs(result.across - result.before) < 1e-6,
|
||||||
|
`1m line kinked across hole from ${result.before} to ${result.across}`);
|
||||||
|
assert.ok(Math.abs(result.after - result.before) < 1e-6,
|
||||||
|
`1m line kinked after hole from ${result.before} to ${result.after}`);
|
||||||
|
assertNoPageErrors(page, assert);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('painted canvas pixels keep the same slope through the live edge',
|
test('painted canvas pixels keep the same slope through the live edge',
|
||||||
{ timeout: 180000 }, async () => {
|
{ timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,16 @@ def test_open_when_no_token_configured(client):
|
||||||
assert client("").get("/api/bars").status_code == 200
|
assert client("").get("/api/bars").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_debug_snap_keeps_geometry_fields(client):
|
||||||
|
response = client("").post("/api/debug/snap", json={
|
||||||
|
"kind": "geometry",
|
||||||
|
"tf": "1m",
|
||||||
|
"gaps": [{"from": 1, "to": 540, "missing": 8, "owned": 8}],
|
||||||
|
"lines": [{"n": 12, "tf": "30m", "pts": 4, "med": 0.1, "bad": []}],
|
||||||
|
})
|
||||||
|
assert response.status_code == 204
|
||||||
|
|
||||||
|
|
||||||
def test_oauth_callback_stays_open_when_a_token_is_set():
|
def test_oauth_callback_stays_open_when_a_token_is_set():
|
||||||
# The provider redirects a browser here and cannot attach the chart token.
|
# The provider redirects a browser here and cannot attach the chart token.
|
||||||
# A 401 would break the login flow at its last step, on production only,
|
# A 401 would break the login flow at its last step, on production only,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
from app.analysis.bar_space import index_at, price_in_bar_space, price_in_timeframe_space
|
from app.analysis.bar_space import (
|
||||||
|
fill_short_gaps, index_at, price_in_bar_space, price_in_timeframe_space,
|
||||||
|
timeframe_index_at,
|
||||||
|
)
|
||||||
from app.analysis.levels import Level, LevelKind, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Timeframe
|
||||||
|
|
||||||
|
|
@ -110,3 +113,25 @@ def test_a_future_endpoint_uses_the_same_source_bucket_projection_as_the_browser
|
||||||
assert price_in_timeframe_space(
|
assert price_in_timeframe_space(
|
||||||
line, source_times, Timeframe.M30, 2 * half_hour,
|
line, source_times, Timeframe.M30, 2 * half_hour,
|
||||||
) == 102.0
|
) == 102.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_1m_hole_still_advances_one_index_per_minute():
|
||||||
|
times = list(range(0, 10 * MINUTE, MINUTE)) + list(range(19 * MINUTE, 30 * MINUTE, MINUTE))
|
||||||
|
filled = fill_short_gaps(times, Timeframe.M1)
|
||||||
|
assert 10 * MINUTE in filled
|
||||||
|
assert timeframe_index_at(filled, 19 * MINUTE, Timeframe.M1) == 19
|
||||||
|
line = sloped(0, 100.0, 30 * MINUTE, 130.0)
|
||||||
|
assert price_in_timeframe_space(line, times, Timeframe.M1, 19 * MINUTE) == 119.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_weekend_is_not_filled_as_short_gaps():
|
||||||
|
weekend = 49 * 3600
|
||||||
|
times = [0, MINUTE, MINUTE + weekend, MINUTE + weekend + MINUTE]
|
||||||
|
assert fill_short_gaps(times, Timeframe.M1) == times
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_5m_bucket_inside_a_ten_minute_hole_is_filled():
|
||||||
|
five = 5 * MINUTE
|
||||||
|
times = [0, five, 3 * five]
|
||||||
|
filled = fill_short_gaps(times, Timeframe.M5)
|
||||||
|
assert filled == [0, five, 2 * five, 3 * five]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue