Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ jobs:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
prompt: '/code-review:code-review --comment ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
claude_args: '--allowedTools "mcp__github_inline_comment__create_inline_comment"'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

2 changes: 1 addition & 1 deletion .github/workflows/claude.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,5 @@ jobs:
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
# claude_args: '--allowed-tools Bash(gh pr *)'

2 changes: 1 addition & 1 deletion backend/app/market/massive_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def __init__(

async def start(self, tickers: list[str]) -> None:
self._client = RESTClient(api_key=self._api_key)
self._tickers = list(tickers)
self._tickers = [t.upper().strip() for t in tickers]

# Do an immediate first poll so the cache has data right away
await self._poll_once()
Expand Down
5 changes: 5 additions & 0 deletions backend/app/market/simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,13 @@ def __init__(
self._task: asyncio.Task | None = None

async def start(self, tickers: list[str]) -> None:
# dt must track the actual tick interval, not the 500ms default, or the
# annualized vol/drift in seed_prices.py silently becomes wrong whenever
# update_interval is anything other than 0.5s.
dt = self._interval / GBMSimulator.TRADING_SECONDS_PER_YEAR
self._sim = GBMSimulator(
tickers=tickers,
dt=dt,
event_probability=self._event_prob,
)
# Seed the cache with initial prices so SSE has data immediately
Expand Down
5 changes: 3 additions & 2 deletions backend/app/market/stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/stream", tags=["streaming"])


def create_stream_router(price_cache: PriceCache) -> APIRouter:
"""Create the SSE streaming router with a reference to the price cache.

This factory pattern lets us inject the PriceCache without globals.
A fresh APIRouter is built on each call so repeated calls (e.g. across
tests) never double-register the route on a shared instance.
"""
router = APIRouter(prefix="/api/stream", tags=["streaming"])

@router.get("/prices")
async def stream_prices(request: Request) -> StreamingResponse:
Expand Down
1 change: 1 addition & 0 deletions backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ dev = [
"pytest-asyncio>=0.24.0",
"pytest-cov>=5.0.0",
"ruff>=0.7.0",
"httpx>=0.27.0",
]

[build-system]
Expand Down
10 changes: 0 additions & 10 deletions backend/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,11 +1 @@
"""Pytest configuration and fixtures."""

import pytest


@pytest.fixture
def event_loop_policy():
"""Use the default event loop policy for all async tests."""
import asyncio

return asyncio.DefaultEventLoopPolicy()
28 changes: 28 additions & 0 deletions backend/tests/market/test_cache.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for PriceCache."""

import threading

from app.market.cache import PriceCache


Expand Down Expand Up @@ -101,3 +103,29 @@ def test_price_rounding(self):
cache = PriceCache()
update = cache.update("AAPL", 190.12345)
assert update.price == 190.12

def test_concurrent_updates_are_not_lost(self):
"""Many threads hammering update() concurrently must not lose or
corrupt writes — the version counter should exactly equal the number
of update() calls, and every ticker should end up with a valid entry.
"""
cache = PriceCache()
tickers = [f"T{i}" for i in range(10)]
updates_per_thread = 200
num_threads = 8

def worker(thread_id: int) -> None:
for i in range(updates_per_thread):
ticker = tickers[(thread_id + i) % len(tickers)]
cache.update(ticker, 100.0 + i)

threads = [threading.Thread(target=worker, args=(t,)) for t in range(num_threads)]
for t in threads:
t.start()
for t in threads:
t.join()

assert cache.version == num_threads * updates_per_thread
assert len(cache) == len(tickers)
for ticker in tickers:
assert cache.get(ticker) is not None
21 changes: 21 additions & 0 deletions backend/tests/market/test_massive.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,24 @@ async def test_start_immediate_poll(self):
assert cache.get_price("AAPL") == 190.50

await source.stop()

async def test_start_normalizes_ticker_case(self):
"""Test that start() uppercases/strips tickers, matching add/remove_ticker.

Regression test: previously start() stored tickers verbatim while
remove_ticker() normalized before filtering, so a ticker passed to
start() in lowercase could never be removed.
"""
cache = PriceCache()
source = MassiveDataSource(api_key="test-key", price_cache=cache, poll_interval=60.0)

with patch("app.market.massive_client.RESTClient"):
with patch.object(source, "_fetch_snapshots", return_value=[]):
await source.start([" aapl ", "googl"])

assert source.get_tickers() == ["AAPL", "GOOGL"]

await source.remove_ticker("aapl")
assert source.get_tickers() == ["GOOGL"]

await source.stop()
44 changes: 33 additions & 11 deletions backend/tests/market/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,55 +10,75 @@ class TestPriceUpdate:

def test_price_update_creation(self):
"""Test basic PriceUpdate creation."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)
assert update.ticker == "AAPL"
assert update.price == 190.50
assert update.previous_price == 190.00
assert update.timestamp == 1234567890.0

def test_change_calculation(self):
"""Test price change calculation."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)
assert update.change == 0.50

def test_change_negative(self):
"""Test negative price change."""
update = PriceUpdate(ticker="AAPL", price=189.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=189.50, previous_price=190.00, timestamp=1234567890.0
)
assert update.change == -0.50

def test_change_percent_up(self):
"""Test percentage change calculation (up)."""
update = PriceUpdate(ticker="AAPL", price=190.00, previous_price=100.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.00, previous_price=100.00, timestamp=1234567890.0
)
assert update.change_percent == 90.0

def test_change_percent_down(self):
"""Test percentage change calculation (down)."""
update = PriceUpdate(ticker="AAPL", price=100.00, previous_price=200.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=100.00, previous_price=200.00, timestamp=1234567890.0
)
assert update.change_percent == -50.0

def test_change_percent_zero_previous(self):
"""Test percentage change with zero previous price."""
update = PriceUpdate(ticker="AAPL", price=100.00, previous_price=0.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=100.00, previous_price=0.00, timestamp=1234567890.0
)
assert update.change_percent == 0.0

def test_direction_up(self):
"""Test direction calculation (up)."""
update = PriceUpdate(ticker="AAPL", price=191.00, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=191.00, previous_price=190.00, timestamp=1234567890.0
)
assert update.direction == "up"

def test_direction_down(self):
"""Test direction calculation (down)."""
update = PriceUpdate(ticker="AAPL", price=189.00, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=189.00, previous_price=190.00, timestamp=1234567890.0
)
assert update.direction == "down"

def test_direction_flat(self):
"""Test direction calculation (flat)."""
update = PriceUpdate(ticker="AAPL", price=190.00, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.00, previous_price=190.00, timestamp=1234567890.0
)
assert update.direction == "flat"

def test_to_dict(self):
"""Test serialization to dictionary."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)
result = update.to_dict()

assert result["ticker"] == "AAPL"
Expand All @@ -71,7 +91,9 @@ def test_to_dict(self):

def test_immutability(self):
"""Test that PriceUpdate is immutable."""
update = PriceUpdate(ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0)
update = PriceUpdate(
ticker="AAPL", price=190.50, previous_price=190.00, timestamp=1234567890.0
)

with pytest.raises(AttributeError):
update.price = 200.00 # Should raise error
21 changes: 19 additions & 2 deletions backend/tests/market/test_simulator.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,23 @@ def test_prices_rounded_to_two_decimals(self):
result = sim.step()
price_str = str(result["AAPL"])
# Check that we have at most 2 decimal places
if '.' in price_str:
decimal_part = price_str.split('.')[1]
if "." in price_str:
decimal_part = price_str.split(".")[1]
assert len(decimal_part) <= 2

def test_full_default_watchlist_builds_valid_cholesky(self):
"""The full 10-ticker default watchlist's correlation matrix (mixing
tech, finance, and TSLA's special-cased correlation) must produce a
valid Cholesky decomposition and step cleanly, not just the 1-2
ticker cases exercised elsewhere in this file."""
tickers = list(SEED_PRICES.keys())
sim = GBMSimulator(tickers=tickers)

assert sim._cholesky is not None
assert sim._cholesky.shape == (len(tickers), len(tickers))

for _ in range(50):
result = sim.step()
assert set(result.keys()) == set(tickers)
for price in result.values():
assert price > 0
23 changes: 19 additions & 4 deletions backend/tests/market/test_simulator_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pytest

from app.market.cache import PriceCache
from app.market.simulator import SimulatorDataSource
from app.market.simulator import GBMSimulator, SimulatorDataSource


@pytest.mark.asyncio
Expand Down Expand Up @@ -128,11 +128,26 @@ async def test_custom_event_probability(self):
"""Test creating source with custom event probability."""
cache = PriceCache()
# Very high event probability for testing
source = SimulatorDataSource(
price_cache=cache, update_interval=0.1, event_probability=1.0
)
source = SimulatorDataSource(price_cache=cache, update_interval=0.1, event_probability=1.0)
await source.start(["AAPL"])

# Just verify it starts and stops cleanly
await asyncio.sleep(0.2)
await source.stop()

async def test_dt_scales_with_update_interval(self):
"""The GBM dt must track update_interval, not always assume 500ms.

Regression test: previously GBMSimulator was always constructed with
its 500ms-derived DEFAULT_DT regardless of update_interval, so a
faster/slower tick rate silently changed the simulator's effective
annualized volatility instead of just its update frequency.
"""
cache = PriceCache()
source = SimulatorDataSource(price_cache=cache, update_interval=0.1)
await source.start(["AAPL"])

expected_dt = 0.1 / GBMSimulator.TRADING_SECONDS_PER_YEAR
assert source._sim._dt == pytest.approx(expected_dt)

await source.stop()
Loading