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>
61 lines
2 KiB
Python
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]
|