Implement M5 manual trendlines

This commit is contained in:
Chris Amow 2026-08-09 20:55:05 -05:00
parent e3ebe2914d
commit 49cd06089f
10 changed files with 393 additions and 24 deletions

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
.env .env
.schwab_token.json
data/manual_lines.json

View file

@ -0,0 +1,110 @@
import json
from dataclasses import asdict, dataclass, replace
from pathlib import Path
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
from app.config import TIMEFRAME_WEIGHT
@dataclass(slots=True)
class ManualLine:
id: str
tf: Timeframe
side: Side
anchor_t: int
anchor_p: float
slope: float
last_t: int
created_at: int
note: str = ""
hidden: bool = False
def to_level(self) -> Level:
return Level(
id=self.id,
kind=LevelKind.MANUAL,
tf=self.tf,
side=self.side,
weight=TIMEFRAME_WEIGHT[self.tf],
score=1.0,
label=f"{self.tf.value} {self.side.value}",
anchor_t=self.anchor_t,
anchor_p=self.anchor_p,
slope=self.slope,
points=None,
touches=0,
first_t=self.anchor_t,
last_t=self.last_t,
provisional=False,
hidden=self.hidden,
)
def to_dict(self) -> dict:
value = asdict(self)
value["tf"] = self.tf.value
value["side"] = self.side.value
return value
@classmethod
def from_dict(cls, value: dict) -> "ManualLine":
return cls(
id=str(value["id"]),
tf=Timeframe(value["tf"]),
side=Side(value["side"]),
anchor_t=int(value["anchor_t"]),
anchor_p=float(value["anchor_p"]),
slope=float(value["slope"]),
last_t=int(value.get("last_t", value["anchor_t"])),
created_at=int(value["created_at"]),
note=str(value.get("note", "")),
hidden=bool(value.get("hidden", False)),
)
class ManualLineStore:
def __init__(self, path: str | Path):
self.path = Path(path)
self.lines: dict[str, ManualLine] = {line.id: line for line in self.load()}
def load(self) -> list[ManualLine]:
if not self.path.exists():
return []
payload = json.loads(self.path.read_text(encoding="utf-8"))
return [ManualLine.from_dict(value) for value in payload]
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
temporary = self.path.with_suffix(self.path.suffix + ".tmp")
temporary.write_text(
json.dumps(
[line.to_dict() for line in self.lines.values()],
indent=2,
sort_keys=True,
)
+ "\n",
encoding="utf-8",
)
temporary.replace(self.path)
def add(self, line: ManualLine) -> ManualLine:
self.lines[line.id] = line
self.save()
return line
def update(self, line_id: str, changes: dict) -> ManualLine:
if line_id not in self.lines:
raise KeyError(line_id)
line = replace(self.lines[line_id], **changes)
self.lines[line_id] = line
self.save()
return line
def delete(self, line_id: str) -> None:
if line_id not in self.lines:
raise KeyError(line_id)
del self.lines[line_id]
self.save()
def levels(self) -> list[Level]:
return [line.to_level() for line in self.lines.values()]

View file

@ -1,10 +1,33 @@
from fastapi import APIRouter, HTTPException, Query, Request import time
import uuid
from fastapi import APIRouter, HTTPException, Query, Request, Response
from pydantic import BaseModel
from app.bars.models import Timeframe from app.bars.models import Timeframe
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine
router = APIRouter(prefix="/api") router = APIRouter(prefix="/api")
class LineCreate(BaseModel):
tf: Timeframe
side: Side
anchor_t: int
anchor_p: float
end_t: int
end_p: float
note: str = ""
hidden: bool = False
class LinePatch(BaseModel):
side: Side | None = None
note: str | None = None
hidden: bool | None = None
@router.get("/health") @router.get("/health")
def health(): def health():
return {"status": "ok", "service": "chart"} return {"status": "ok", "service": "chart"}
@ -52,3 +75,46 @@ def confluence(request: Request):
"price": runtime.price, "price": runtime.price,
"clusters": [cluster.to_dict() for cluster in runtime.clusters], "clusters": [cluster.to_dict() for cluster in runtime.clusters],
} }
@router.post("/lines", status_code=201)
def create_line(request: Request, payload: LineCreate):
if payload.end_t == payload.anchor_t:
raise HTTPException(400, "Line endpoints must have different times")
line = ManualLine(
id=f"ml_{uuid.uuid4().hex}",
tf=payload.tf,
side=payload.side,
anchor_t=payload.anchor_t,
anchor_p=payload.anchor_p,
slope=(payload.end_p - payload.anchor_p) / (payload.end_t - payload.anchor_t),
last_t=payload.end_t,
created_at=int(time.time()),
note=payload.note,
hidden=payload.hidden,
)
runtime = request.app.state.runtime
runtime.manual_lines.add(line)
runtime.rebuild_levels()
return line.to_level().to_dict()
@router.patch("/lines/{line_id}")
def patch_line(request: Request, line_id: str, payload: LinePatch):
changes = payload.model_dump(exclude_none=True)
try:
line = request.app.state.runtime.manual_lines.update(line_id, changes)
except KeyError as exc:
raise HTTPException(404, "Line not found") from exc
request.app.state.runtime.rebuild_levels()
return line.to_level().to_dict()
@router.delete("/lines/{line_id}", status_code=204)
def delete_line(request: Request, line_id: str):
try:
request.app.state.runtime.manual_lines.delete(line_id)
except KeyError as exc:
raise HTTPException(404, "Line not found") from exc
request.app.state.runtime.rebuild_levels()
return Response(status_code=204)

View file

@ -60,6 +60,7 @@ async def websocket_endpoint(websocket: WebSocket):
async def receive(): async def receive():
nonlocal tf, prefs nonlocal tf, prefs
try:
while True: while True:
message = await websocket.receive_json() message = await websocket.receive_json()
if message.get("type") == "subscribe": if message.get("type") == "subscribe":
@ -75,11 +76,15 @@ async def websocket_endpoint(websocket: WebSocket):
"clusters": [cluster.to_dict() for cluster in clusters], "clusters": [cluster.to_dict() for cluster in clusters],
} }
) )
except WebSocketDisconnect:
queue.put_nowait({"type": "disconnect"})
receiver = asyncio.create_task(receive()) receiver = asyncio.create_task(receive())
try: try:
while True: while True:
event = await queue.get() event = await queue.get()
if event["type"] == "disconnect":
break
if event["type"] == "bar" and event["bar"].tf is tf: if event["type"] == "bar" and event["bar"].tf is tf:
await websocket.send_json( await websocket.send_json(
{"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()} {"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()}

View file

@ -7,6 +7,7 @@ from app.analysis.levels import Level
from app.analysis.moving_averages import build_ma_levels from app.analysis.moving_averages import build_ma_levels
from app.analysis.confluence import Cluster, cluster_levels from app.analysis.confluence import Cluster, cluster_levels
from app.analysis.indicators import atr from app.analysis.indicators import atr
from app.analysis.manual_lines import ManualLineStore
from app.bars.store import InMemoryBarStore from app.bars.store import InMemoryBarStore
from app.config import Settings from app.config import Settings
from app.market.factory import live_source, seed_source from app.market.factory import live_source, seed_source
@ -24,14 +25,19 @@ class Runtime:
clusters: list[Cluster] = field(default_factory=list) clusters: list[Cluster] = field(default_factory=list)
price: float | None = None price: float | None = None
atr15: float = 0.0 atr15: float = 0.0
manual_lines: ManualLineStore = field(init=False)
ma_levels: list[Level] = field(default_factory=list)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.store = InMemoryBarStore(self.settings.max_bars_per_tf) self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
self.aggregator = Aggregator(self.settings.enabled_timeframes) self.aggregator = Aggregator(self.settings.enabled_timeframes)
self.manual_lines = ManualLineStore(self.settings.manual_lines_path)
self.levels = self.manual_lines.levels()
self.stream = StreamService(live_source(self.settings), self.settings.yahoo_symbol) self.stream = StreamService(live_source(self.settings), self.settings.yahoo_symbol)
self.stream.add_handler(self.on_bar) self.stream.add_handler(self.on_bar)
async def on_bar(self, bar: Bar) -> None: async def on_bar(self, bar: Bar) -> None:
evaluate_alerts = False
for aggregated in self.aggregator.update(bar): for aggregated in self.aggregator.update(bar):
self.store.put(aggregated) self.store.put(aggregated)
self.broadcast({"type": "bar", "bar": aggregated}) self.broadcast({"type": "bar", "bar": aggregated})
@ -39,6 +45,8 @@ class Runtime:
self.rebuild_levels() self.rebuild_levels()
if aggregated.tf is Timeframe.M1 and aggregated.closed: if aggregated.tf is Timeframe.M1 and aggregated.closed:
self.price = aggregated.c self.price = aggregated.c
evaluate_alerts = True
if evaluate_alerts:
values = atr(self.store.get(Timeframe.M15), 14) values = atr(self.store.get(Timeframe.M15), 14)
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0) self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
self.rebuild_clusters(evaluate_alerts=True) self.rebuild_clusters(evaluate_alerts=True)
@ -50,10 +58,11 @@ class Runtime:
queue.put_nowait(event) queue.put_nowait(event)
def rebuild_levels(self) -> None: def rebuild_levels(self) -> None:
self.levels = build_ma_levels( self.ma_levels = build_ma_levels(
{tf: self.store.get(tf) for tf in self.settings.ma_sets}, {tf: self.store.get(tf) for tf in self.settings.ma_sets},
self.settings.ma_sets, self.settings.ma_sets,
) )
self.levels = self.ma_levels + self.manual_lines.levels()
self.broadcast({"type": "levels", "levels": self.levels}) self.broadcast({"type": "levels", "levels": self.levels})
self.rebuild_clusters() self.rebuild_clusters()

View file

@ -16,6 +16,11 @@ createApp({
const levels = ref([]); const levels = ref([]);
const clusters = ref([]); const clusters = ref([]);
const alerts = ref([]); const alerts = ref([]);
const drawMode = ref(false);
const drawSide = ref('support');
const snap = ref(true);
const drawPoints = ref([]);
const selectedLine = ref(null);
const timeframes = ['1m', '2m', '5m', '15m', '30m', '1h', '4h', '1d']; const timeframes = ['1m', '2m', '5m', '15m', '30m', '1h', '4h', '1d'];
const now = ref(Date.now()); const now = ref(Date.now());
let chartApi = null; let chartApi = null;
@ -81,6 +86,68 @@ createApp({
oscillator.stop(context.currentTime + 0.35); oscillator.stop(context.currentTime + 0.35);
} }
function toggleDraw() {
drawMode.value = !drawMode.value;
drawPoints.value = [];
selectedLine.value = null;
}
async function handleChartClick(param) {
if (!drawMode.value) {
selectedLine.value = chartApi.hitTest(param);
return;
}
const point = chartApi.pointFromClick(param, snap.value);
if (point.snappedSide) drawSide.value = point.snappedSide;
drawPoints.value.push(point);
if (drawPoints.value.length < 2) return;
const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t);
if (start.t === end.t) { drawPoints.value = []; return; }
const temporaryId = `tmp_${Date.now()}`;
const optimistic = {
id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value,
weight: 1, score: 1, label: `${timeframe.value} ${drawSide.value}`,
anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t),
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
};
levels.value.push(optimistic);
syncVisibleLevels();
drawPoints.value = [];
drawMode.value = false;
try {
const response = await fetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p }),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json();
levels.value = levels.value.filter(level => level.id !== temporaryId);
if (!levels.value.some(level => level.id === saved.id)) levels.value.push(saved);
selectedLine.value = saved.id;
} catch (error) {
levels.value = levels.value.filter(level => level.id !== temporaryId);
console.error('Unable to save line', error);
}
syncVisibleLevels();
}
async function deleteSelected() {
if (!selectedLine.value) return;
const id = selectedLine.value;
levels.value = levels.value.filter(level => level.id !== id);
selectedLine.value = null;
syncVisibleLevels();
const response = await fetch(`/api/lines/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!response.ok) console.error(`Unable to delete line: HTTP ${response.status}`);
}
function handleKeydown(event) {
if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) {
event.preventDefault();
deleteSelected();
}
}
function selectTimeframe(tf) { function selectTimeframe(tf) {
timeframe.value = tf; timeframe.value = tf;
prefs.value.base_tf = tf; prefs.value.base_tf = tf;
@ -123,6 +190,8 @@ createApp({
onMounted(() => { onMounted(() => {
chartApi = new ConfluenceChart(); chartApi = new ConfluenceChart();
chartApi.create(document.getElementById('chart')); chartApi.create(document.getElementById('chart'));
chartApi.setClickHandler(handleChartClick);
window.addEventListener('keydown', handleKeydown);
refreshStatus(); refreshStatus();
connect(); connect();
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000); timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
@ -131,8 +200,9 @@ createApp({
clearInterval(timer); clearInterval(timer);
if (socket) socket.close(); if (socket) socket.close();
if (chartApi) chartApi.destroy(); if (chartApi) chartApi.destroy();
window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, selectTimeframe, allEnabled, toggleGroup }; return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawSide, snap, drawPoints, selectedLine, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -4,6 +4,9 @@ class ConfluenceChart {
this.candles = null; this.candles = null;
this.resizeObserver = null; this.resizeObserver = null;
this.levelSeries = new Map(); this.levelSeries = new Map();
this.bars = [];
this.levels = [];
this.onChartClick = null;
} }
create(el) { create(el) {
@ -28,16 +31,25 @@ class ConfluenceChart {
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight }); this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
}); });
this.resizeObserver.observe(el); this.resizeObserver.observe(el);
this.chart.subscribeClick(param => {
if (param.point && param.time && this.onChartClick) this.onChartClick(param);
});
} }
setBars(bars) { setBars(bars) {
this.bars = bars;
this.candles.setData(bars.map(this.toCandle)); this.candles.setData(bars.map(this.toCandle));
this.chart.timeScale().fitContent(); this.chart.timeScale().fitContent();
} }
updateBar(bar) { this.candles.update(this.toCandle(bar)); } updateBar(bar) {
this.candles.update(this.toCandle(bar));
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
else this.bars.push(bar);
}
syncLevels(levels) { syncLevels(levels) {
this.levels = levels;
const wanted = new Set(levels.filter(level => !level.hidden).map(level => level.id)); const wanted = new Set(levels.filter(level => !level.hidden).map(level => level.id));
for (const [id, entry] of this.levelSeries) { for (const [id, entry] of this.levelSeries) {
if (!wanted.has(id)) { if (!wanted.has(id)) {
@ -48,10 +60,11 @@ class ConfluenceChart {
for (const level of levels) { for (const level of levels) {
if (level.hidden) continue; if (level.hidden) continue;
let entry = this.levelSeries.get(level.id); let entry = this.levelSeries.get(level.id);
const isMa = level.kind === 'ma';
const options = { const options = {
color: ConfluenceChart.tfColors[level.tf], color: ConfluenceChart.tfColors[level.tf],
lineWidth: level.tf === '1d' ? 2 : 1, lineWidth: level.tf === '1d' ? 2 : 1,
lineType: LightweightCharts.LineType.WithSteps, lineType: isMa ? LightweightCharts.LineType.WithSteps : LightweightCharts.LineType.Simple,
lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid, lineStyle: level.provisional ? LightweightCharts.LineStyle.Dashed : LightweightCharts.LineStyle.Solid,
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: true, lastValueVisible: true,
@ -63,8 +76,51 @@ class ConfluenceChart {
} else { } else {
entry.series.applyOptions(options); entry.series.applyOptions(options);
} }
entry.series.setData((level.points || []).map(([time, value]) => ({ time, value }))); let data;
if (isMa) {
data = (level.points || []).map(([time, value]) => ({ time, value }));
} else {
const first = this.bars[0]?.t || level.anchor_t;
const last = this.bars[this.bars.length - 1]?.t || level.last_t;
const extension = Math.max(60, Math.floor((last - first) * 0.2));
const end = Math.max(last, level.last_t) + extension;
data = [
{ time: level.anchor_t, value: level.anchor_p },
{ time: end, value: level.anchor_p + level.slope * (end - level.anchor_t) },
];
} }
entry.series.setData(data);
}
}
setClickHandler(handler) { this.onChartClick = handler; }
pointFromClick(param, snap) {
const rawPrice = this.candles.coordinateToPrice(param.point.y);
let point = { t: Number(param.time), p: rawPrice, snappedSide: null };
if (!snap || !this.bars.length) return point;
const nearest = this.bars.reduce((best, bar) => Math.abs(bar.t - point.t) < Math.abs(best.t - point.t) ? bar : best);
const candidates = [
{ p: nearest.h, side: 'resistance' },
{ p: nearest.l, side: 'support' },
];
const snapped = candidates
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - param.point.y) }))
.sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) point = { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return point;
}
hitTest(param) {
const t = Number(param.time);
let best = null;
for (const level of this.levels.filter(value => value.kind === 'manual')) {
const price = level.anchor_p + level.slope * (t - level.anchor_t);
const coordinate = this.candles.priceToCoordinate(price);
const distance = Math.abs(coordinate - param.point.y);
if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance };
}
return best?.id || null;
} }
toCandle(bar) { toCandle(bar) {

View file

@ -20,6 +20,14 @@
<div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div> <div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div>
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div> <div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</div> </div>
<div class="drawing-tools">
<button :class="{active: drawMode}" @click="toggleDraw">Trendline</button>
<select v-model="drawSide" aria-label="Line side"><option value="support">Support</option><option value="resistance">Resistance</option></select>
<label><input type="checkbox" v-model="snap">Snap</label>
<button @click="deleteSelected" :disabled="!selectedLine">Delete</button>
<span v-if="drawMode">{{ drawPoints.length ? 'Place second point' : 'Place first point' }}</span>
<span v-else-if="selectedLine">Line selected</span>
</div>
<div id="chart"></div> <div id="chart"></div>
<div class="statusbar"> <div class="statusbar">
<span>FEED <b>{{ status.stream }}</b></span> <span>FEED <b>{{ status.stream }}</b></span>

View file

@ -13,6 +13,7 @@ main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
.symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; } .symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); } button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; } .timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select { padding:4px 8px; font-size:10px; }.drawing-tools select { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { height:calc(100vh - 190px); min-height:420px; } #chart { height:calc(100vh - 190px); min-height:420px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; } .statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; } aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }

View file

@ -0,0 +1,42 @@
from app.analysis.levels import Side
from app.analysis.manual_lines import ManualLine, ManualLineStore
from app.analysis.confluence import cluster_levels
from app.analysis.levels import Level, LevelKind
from app.bars.models import Timeframe
def sample_line():
return ManualLine("ml_test", Timeframe.H4, Side.RESISTANCE, 100, 5000, -0.01, 200, 300)
def test_json_persistence_round_trip(tmp_path):
path = tmp_path / "manual_lines.json"
store = ManualLineStore(path)
store.add(sample_line())
loaded = ManualLineStore(path)
assert list(loaded.lines.values()) == [sample_line()]
loaded.update("ml_test", {"note": "major swing"})
assert ManualLineStore(path).lines["ml_test"].note == "major swing"
loaded.delete("ml_test")
assert ManualLineStore(path).lines == {}
def test_four_hour_line_uses_absolute_time_on_one_minute_chart():
level = sample_line().to_level()
instant = 160
assert level.tf is Timeframe.H4
assert level.price_at(instant) == 4999.4
assert level.weight == 8
def test_manual_line_raises_existing_ma_cluster_score():
ma = Level(
"ma", LevelKind.MA, Timeframe.D1, Side.RESISTANCE, 12, 1, "1d SMA20",
100, 5000, 0, None, 0, 100, 100, False, False, 20,
)
before = cluster_levels([ma], 160, 4998, 2)[0]
after = cluster_levels([ma, sample_line().to_level()], 160, 4998, 2)[0]
assert before.score == 12
assert after.score == 20