Implement M5 manual trendlines
This commit is contained in:
parent
e3ebe2914d
commit
49cd06089f
10 changed files with 393 additions and 24 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ __pycache__/
|
|||
*.pyc
|
||||
.venv/
|
||||
.env
|
||||
.schwab_token.json
|
||||
data/manual_lines.json
|
||||
|
|
|
|||
110
app/analysis/manual_lines.py
Normal file
110
app/analysis/manual_lines.py
Normal 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()]
|
||||
|
|
@ -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.analysis.levels import Side
|
||||
from app.analysis.manual_lines import ManualLine
|
||||
|
||||
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")
|
||||
def health():
|
||||
return {"status": "ok", "service": "chart"}
|
||||
|
|
@ -52,3 +75,46 @@ def confluence(request: Request):
|
|||
"price": runtime.price,
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -60,26 +60,31 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
|
||||
async def receive():
|
||||
nonlocal tf, prefs
|
||||
while True:
|
||||
message = await websocket.receive_json()
|
||||
if message.get("type") == "subscribe":
|
||||
tf = Timeframe(message.get("tf", "1m"))
|
||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||
elif message.get("type") == "prefs":
|
||||
prefs = message
|
||||
clusters = connection_clusters(runtime, prefs)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "clusters",
|
||||
"price": runtime.price,
|
||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||
}
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive_json()
|
||||
if message.get("type") == "subscribe":
|
||||
tf = Timeframe(message.get("tf", "1m"))
|
||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||
elif message.get("type") == "prefs":
|
||||
prefs = message
|
||||
clusters = connection_clusters(runtime, prefs)
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "clusters",
|
||||
"price": runtime.price,
|
||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
queue.put_nowait({"type": "disconnect"})
|
||||
|
||||
receiver = asyncio.create_task(receive())
|
||||
try:
|
||||
while True:
|
||||
event = await queue.get()
|
||||
if event["type"] == "disconnect":
|
||||
break
|
||||
if event["type"] == "bar" and event["bar"].tf is tf:
|
||||
await websocket.send_json(
|
||||
{"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from app.analysis.levels import Level
|
|||
from app.analysis.moving_averages import build_ma_levels
|
||||
from app.analysis.confluence import Cluster, cluster_levels
|
||||
from app.analysis.indicators import atr
|
||||
from app.analysis.manual_lines import ManualLineStore
|
||||
from app.bars.store import InMemoryBarStore
|
||||
from app.config import Settings
|
||||
from app.market.factory import live_source, seed_source
|
||||
|
|
@ -24,14 +25,19 @@ class Runtime:
|
|||
clusters: list[Cluster] = field(default_factory=list)
|
||||
price: float | None = None
|
||||
atr15: float = 0.0
|
||||
manual_lines: ManualLineStore = field(init=False)
|
||||
ma_levels: list[Level] = field(default_factory=list)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.store = InMemoryBarStore(self.settings.max_bars_per_tf)
|
||||
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.add_handler(self.on_bar)
|
||||
|
||||
async def on_bar(self, bar: Bar) -> None:
|
||||
evaluate_alerts = False
|
||||
for aggregated in self.aggregator.update(bar):
|
||||
self.store.put(aggregated)
|
||||
self.broadcast({"type": "bar", "bar": aggregated})
|
||||
|
|
@ -39,9 +45,11 @@ class Runtime:
|
|||
self.rebuild_levels()
|
||||
if aggregated.tf is Timeframe.M1 and aggregated.closed:
|
||||
self.price = aggregated.c
|
||||
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.rebuild_clusters(evaluate_alerts=True)
|
||||
evaluate_alerts = True
|
||||
if evaluate_alerts:
|
||||
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.rebuild_clusters(evaluate_alerts=True)
|
||||
|
||||
def broadcast(self, event: dict) -> None:
|
||||
for queue in self.subscribers.copy():
|
||||
|
|
@ -50,10 +58,11 @@ class Runtime:
|
|||
queue.put_nowait(event)
|
||||
|
||||
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},
|
||||
self.settings.ma_sets,
|
||||
)
|
||||
self.levels = self.ma_levels + self.manual_lines.levels()
|
||||
self.broadcast({"type": "levels", "levels": self.levels})
|
||||
self.rebuild_clusters()
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ createApp({
|
|||
const levels = ref([]);
|
||||
const clusters = 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 now = ref(Date.now());
|
||||
let chartApi = null;
|
||||
|
|
@ -81,6 +86,68 @@ createApp({
|
|||
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) {
|
||||
timeframe.value = tf;
|
||||
prefs.value.base_tf = tf;
|
||||
|
|
@ -123,6 +190,8 @@ createApp({
|
|||
onMounted(() => {
|
||||
chartApi = new ConfluenceChart();
|
||||
chartApi.create(document.getElementById('chart'));
|
||||
chartApi.setClickHandler(handleChartClick);
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
refreshStatus();
|
||||
connect();
|
||||
timer = setInterval(() => { now.value = Date.now(); refreshStatus(); }, 5000);
|
||||
|
|
@ -131,8 +200,9 @@ createApp({
|
|||
clearInterval(timer);
|
||||
if (socket) socket.close();
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ class ConfluenceChart {
|
|||
this.candles = null;
|
||||
this.resizeObserver = null;
|
||||
this.levelSeries = new Map();
|
||||
this.bars = [];
|
||||
this.levels = [];
|
||||
this.onChartClick = null;
|
||||
}
|
||||
|
||||
create(el) {
|
||||
|
|
@ -28,16 +31,25 @@ class ConfluenceChart {
|
|||
this.chart.applyOptions({ width: el.clientWidth, height: el.clientHeight });
|
||||
});
|
||||
this.resizeObserver.observe(el);
|
||||
this.chart.subscribeClick(param => {
|
||||
if (param.point && param.time && this.onChartClick) this.onChartClick(param);
|
||||
});
|
||||
}
|
||||
|
||||
setBars(bars) {
|
||||
this.bars = bars;
|
||||
this.candles.setData(bars.map(this.toCandle));
|
||||
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) {
|
||||
this.levels = levels;
|
||||
const wanted = new Set(levels.filter(level => !level.hidden).map(level => level.id));
|
||||
for (const [id, entry] of this.levelSeries) {
|
||||
if (!wanted.has(id)) {
|
||||
|
|
@ -48,10 +60,11 @@ class ConfluenceChart {
|
|||
for (const level of levels) {
|
||||
if (level.hidden) continue;
|
||||
let entry = this.levelSeries.get(level.id);
|
||||
const isMa = level.kind === 'ma';
|
||||
const options = {
|
||||
color: ConfluenceChart.tfColors[level.tf],
|
||||
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,
|
||||
priceLineVisible: false,
|
||||
lastValueVisible: true,
|
||||
|
|
@ -63,10 +76,53 @@ class ConfluenceChart {
|
|||
} else {
|
||||
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) {
|
||||
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,14 @@
|
|||
<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>
|
||||
<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 class="statusbar">
|
||||
<span>FEED <b>{{ status.stream }}</b></span>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
|
|
|||
42
tests/test_manual_lines.py
Normal file
42
tests/test_manual_lines.py
Normal 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
|
||||
Loading…
Reference in a new issue