chart/tests/test_confluence.py
Chris Amow 45802c1a2f Fix trendline deletion while typing, audio leak, prefs drift, cluster payload
Five defects found by exercising the running app rather than reading it:

- Backspace inside the sidebar rename field deleted the trendline instead of
  a character. The window keydown handler never checked what was focused, so
  correcting a typo in a line's name destroyed the line.
- playAlert() built a new AudioContext per alert and never closed it. Browsers
  cap a document at roughly six, after which alerts stop making any sound.
  One shared context now, with nodes released on end and a resume() for the
  autoplay policy.
- Stored layer preferences were used verbatim, so any key added to
  defaultPrefs later would be missing for existing visitors. A missing
  enabled.ma is a crash rather than a cosmetic gap. They are now deep-merged
  onto the defaults, and unparseable state falls back instead of throwing.
- The alert log keyed rows on a second-resolution timestamp, so two alerts in
  the same second collided.
- Clusters embedded whole Level objects, including a moving average's entire
  point history — hundreds of entries reaching back years. Because clusters
  are re-sent on every closed 1m bar, this shipped the whole levels payload
  once a minute. Members are now compact summaries and the client joins on
  id; Cluster.to_dict() also stops round-tripping through asdict(), which was
  deep-copying those arrays before discarding them.

/api/confluence drops from 61,838 to 1,245 bytes with five clusters live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 00:17:40 -05:00

61 lines
2 KiB
Python

from app.analysis.confluence import cluster_levels
from app.analysis.levels import Level, LevelKind, Side
from app.bars.models import Timeframe
def level(id_: str, price: float, weight: float, tf=Timeframe.H1):
return Level(id_, LevelKind.MA, tf, Side.RESISTANCE, weight, 1, id_, 100, price, 0, None, 0, 100, 100, False, False)
def test_single_linkage_cluster_has_known_score_and_effective_side():
clusters = cluster_levels(
[level("a", 99.8, 2), level("b", 100.1, 4), level("c", 105, 1)],
current_t=200,
current_price=99,
atr15=1,
)
assert len(clusters) == 1
assert clusters[0].low == 99.8
assert clusters[0].high == 100.1
assert clusters[0].score == 6
assert clusters[0].side is Side.RESISTANCE
def test_lone_daily_level_is_emitted():
clusters = cluster_levels([level("daily", 98, 12, Timeframe.D1)], 200, 100, 1)
assert len(clusters) == 1
assert clusters[0].side is Side.SUPPORT
def test_levels_on_opposite_sides_of_price_do_not_cluster():
clusters = cluster_levels([level("below", 99.9, 2), level("above", 100.1, 2)], 200, 100, 1)
assert clusters == []
def test_level_ended_before_current_time_is_excluded():
ended = level("ended", 98, 12, Timeframe.D1)
ended.cutoff_t = 150
assert cluster_levels([ended], 200, 100, 1) == []
def test_cluster_members_omit_point_history():
# Clusters are re-sent on every closed 1m bar. A moving average's point
# history is hundreds of entries, so embedding whole levels here shipped the
# entire levels payload once a minute.
heavy = level("ma", 98, 12, Timeframe.D1)
heavy.points = [(t, 1.0) for t in range(600)]
payload = cluster_levels([heavy], 200, 100, 1)[0].to_dict()
assert payload["members"] == [
{
"id": "ma",
"kind": "ma",
"tf": "1d",
"side": "resistance",
"weight": 12,
"label": "ma",
}
]
assert "points" not in payload["members"][0]