Two faults, both of which made a finished line disagree with its own preview. A placed anchor was being discarded by a twitch. finishToolGesture recomputed `dragged` from the second click's own pointerdown/up, so a few pixels of movement while pressing was read as a fresh press-drag-release: the anchor from the first click was thrown away and the line began at the second click instead, to the right of where it was meant to start. The preview had been rubber-banding from the real anchor the whole time, which is why the result jumped on commit. A pending anchor now wins over the current click's drag. Snapping ignored where you pointed. snapPoint took the nearer of the bar's high and low but only within 8px, so a cursor between the two snapped to neither and returned a raw mid-bar price — and the side quietly fell back to the dropdown. The gate is gone: with snapping on, an anchor always lands on the nearer extreme of the nearest bar, and that choice *is* the side, a high being resistance and a low support. app.js already preferred snappedSide over the dropdown, so the inference was written and simply never fired. snapPoint also guards against being handed an already-snapped point, which carries no cursor y and previously compared against NaN. Verified by driving the gesture in a browser: click, move, then a second click with 4px of movement while pressed now yields anchor_t equal to the first click's time and anchor_p exactly equal to the bar's high. Also lands the groundwork for chart comments, inert until the UI is wired: a `kind` on ManualLine with `pinned`, `x`, `y` and `collapsed`, a POST /comments endpoint, GET /drawings, and the chart's DOM comment layer. Comments are stored with the lines so they share numbering, filtering and deletion, and ManualLineStore.levels() excludes them — a comment reaching the level list would join a confluence cluster and push a notification about a piece of text. Tests cover the exclusion, the derived kind for lines saved before comments existed, and that numbering is shared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
91 lines
3.6 KiB
Python
91 lines
3.6 KiB
Python
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.H1, Side.RESISTANCE, 100, 5000, -0.01, 200, 300, number=1)
|
|
|
|
|
|
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_hourly_line_uses_absolute_time_on_one_minute_chart():
|
|
level = sample_line().to_level()
|
|
instant = 160
|
|
assert level.tf is Timeframe.H1
|
|
assert level.price_at(instant) == 4999.4
|
|
assert level.weight == 4
|
|
|
|
|
|
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 == 16
|
|
|
|
|
|
def test_a_comment_is_never_a_level(tmp_path):
|
|
# Comments live with the lines so they share numbering, filtering and
|
|
# deletion — but a comment reaching levels() would join a confluence
|
|
# cluster and fire a push notification about a piece of text.
|
|
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
|
from app.analysis.levels import Side
|
|
from app.bars.models import Timeframe
|
|
|
|
store = ManualLineStore(tmp_path / "lines.json")
|
|
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
|
anchor_t=1000, last_t=2000, created_at=1000)
|
|
store.add(ManualLine(id="ml_level", slope=0.0, **common))
|
|
store.add(ManualLine(id="ml_note", slope=0.0, kind="comment",
|
|
note="watch this", **common))
|
|
|
|
assert [level.id for level in store.levels()] == ["ml_level"]
|
|
assert [line.id for line in store.drawings()] == ["ml_level", "ml_note"]
|
|
|
|
|
|
def test_drawing_kind_is_derived_for_lines_saved_before_comments(tmp_path):
|
|
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
|
from app.analysis.levels import Side
|
|
from app.bars.models import Timeframe
|
|
|
|
store = ManualLineStore(tmp_path / "lines.json")
|
|
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
|
anchor_t=1000, last_t=2000, created_at=1000)
|
|
flat = store.add(ManualLine(id="ml_flat", slope=0.0, **common))
|
|
sloped = store.add(ManualLine(id="ml_sloped", slope=0.5, **common))
|
|
|
|
assert flat.drawing_kind == "level"
|
|
assert sloped.drawing_kind == "trendline"
|
|
|
|
|
|
def test_every_drawing_gets_a_number_including_comments(tmp_path):
|
|
from app.analysis.manual_lines import ManualLine, ManualLineStore
|
|
from app.analysis.levels import Side
|
|
from app.bars.models import Timeframe
|
|
|
|
store = ManualLineStore(tmp_path / "lines.json")
|
|
common = dict(tf=Timeframe.M1, side=Side.SUPPORT, anchor_p=100.0,
|
|
anchor_t=1000, last_t=2000, created_at=1000)
|
|
first = store.add(ManualLine(id="ml_a", slope=0.0, **common))
|
|
note = store.add(ManualLine(id="ml_b", slope=0.0, kind="comment", **common))
|
|
third = store.add(ManualLine(id="ml_c", slope=1.0, **common))
|
|
|
|
assert [first.number, note.number, third.number] == [1, 2, 3]
|