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
|
*.pyc
|
||||||
.venv/
|
.venv/
|
||||||
.env
|
.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.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)
|
||||||
|
|
|
||||||
|
|
@ -60,26 +60,31 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
|
|
||||||
async def receive():
|
async def receive():
|
||||||
nonlocal tf, prefs
|
nonlocal tf, prefs
|
||||||
while True:
|
try:
|
||||||
message = await websocket.receive_json()
|
while True:
|
||||||
if message.get("type") == "subscribe":
|
message = await websocket.receive_json()
|
||||||
tf = Timeframe(message.get("tf", "1m"))
|
if message.get("type") == "subscribe":
|
||||||
await websocket.send_json(snapshot(runtime, tf, prefs))
|
tf = Timeframe(message.get("tf", "1m"))
|
||||||
elif message.get("type") == "prefs":
|
await websocket.send_json(snapshot(runtime, tf, prefs))
|
||||||
prefs = message
|
elif message.get("type") == "prefs":
|
||||||
clusters = connection_clusters(runtime, prefs)
|
prefs = message
|
||||||
await websocket.send_json(
|
clusters = connection_clusters(runtime, prefs)
|
||||||
{
|
await websocket.send_json(
|
||||||
"type": "clusters",
|
{
|
||||||
"price": runtime.price,
|
"type": "clusters",
|
||||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
"price": runtime.price,
|
||||||
}
|
"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()}
|
||||||
|
|
|
||||||
|
|
@ -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,9 +45,11 @@ 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
|
||||||
values = atr(self.store.get(Timeframe.M15), 14)
|
evaluate_alerts = True
|
||||||
self.atr15 = next((value for value in reversed(values) if value is not None), 0.0)
|
if evaluate_alerts:
|
||||||
self.rebuild_clusters(evaluate_alerts=True)
|
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:
|
def broadcast(self, event: dict) -> None:
|
||||||
for queue in self.subscribers.copy():
|
for queue in self.subscribers.copy():
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
|
|
@ -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,10 +76,53 @@ 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) {
|
||||||
return { time: bar.t, open: bar.o, high: bar.h, low: bar.l, close: bar.c };
|
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><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>
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
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