Skip to content
Merged
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
5 changes: 3 additions & 2 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@
"Bash(gh issue close:*)"
],
"deny": []
}
}
},
"enableAllProjectMcpServers": false
}
8 changes: 0 additions & 8 deletions .flox/env/manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,6 @@ nushell.pkg-path = "nushell"
fd.pkg-path = "fd"
fselect.pkg-path = "fselect"

[vars]
ALPACA_API_KEY = "${ALPACA_API_KEY}"
ALPACA_API_SECRET = "${ALPACA_API_SECRET}"
POLYGON_API_KEY = "${POLYGON_API_KEY}"
DATA_BUCKET = "${DATA_BUCKET}"
DUCKDB_ACCESS_KEY = "${DUCKDB_ACCESS_KEY}"
DUCKDB_SECRET = "${DUCKDB_SECRET}"

[options]
systems = [
"aarch64-darwin",
Expand Down
2 changes: 1 addition & 1 deletion application/datamanager/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.12
FROM python:3.12.10

COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv

Expand Down
2 changes: 1 addition & 1 deletion application/datamanager/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "datamanager"
version = "0.1.0"
description = "Data management service"
requires-python = "==3.13"
requires-python = "==3.12.10"
dependencies = [
"fastapi>=0.115.12",
"uvicorn>=0.34.2",
Expand Down
6 changes: 3 additions & 3 deletions application/datamanager/src/datamanager/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,8 @@ async def fetch_equity_bars(request: Request, summary_date: SummaryDate) -> Bars
polygon = request.app.state.settings.polygon
bucket = request.app.state.settings.gcp.bucket

summary_date: str = summary_date.date.strftime("%Y-%m-%d")
url = f"{polygon.base_url}{polygon.daily_bars}{summary_date}"
request_summary_date: str = summary_date.date.strftime("%Y-%m-%d")
url = f"{polygon.base_url}{polygon.daily_bars}{request_summary_date}"
logger.info(f"polygon_api_endpoint={url}")

params = {"adjusted": "true", "apiKey": polygon.api_key}
Expand Down Expand Up @@ -178,7 +178,7 @@ async def fetch_equity_bars(request: Request, summary_date: SummaryDate) -> Bars
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to write data",
) from e
return BarsSummary(date=summary_date, count=count)
return BarsSummary(date=request_summary_date, count=count)


@application.delete("/equity-bars")
Expand Down
93 changes: 93 additions & 0 deletions application/datamanager/tests/test_datamanager_main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import unittest
from datetime import date
from unittest.mock import MagicMock, patch

from fastapi import status
from fastapi.testclient import TestClient

from application.datamanager.src.datamanager.main import application
from application.datamanager.src.datamanager.models import BarsSummary, SummaryDate

client = TestClient(application)


def test_health_check() -> None:
response = client.get("/health")
assert response.status_code == status.HTTP_200_OK


class TestDataManagerModels(unittest.TestCase):
def test_summary_date_default(self) -> None:
summary_date = SummaryDate()
assert isinstance(summary_date.date, date)

def test_summary_date_with_date(self) -> None:
test_date = date(2023, 1, 1)
summary_date = SummaryDate(date=test_date)
assert summary_date.date == test_date

def test_summary_date_string_parsing(self) -> None:
summary_date = SummaryDate(date="2023-01-01") # type: ignore
assert summary_date.date == date(2023, 1, 1)

def test_bars_summary_creation(self) -> None:
bars_summary = BarsSummary(date="2023-01-01", count=100)
assert bars_summary.date == "2023-01-01"
assert bars_summary.count == 100 # noqa: PLR2004


class TestEquityBarsEndpoints(unittest.TestCase):
def test_get_equity_bars_missing_parameters(self) -> None:
response = client.get("/equity-bars")
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

def test_get_equity_bars_invalid_date_format(self) -> None:
response = client.get(
"/equity-bars",
params={"start_date": "invalid-date", "end_date": "2023-01-02"},
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

def test_post_equity_bars_missing_body(self) -> None:
response = client.post("/equity-bars")
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

def test_post_equity_bars_invalid_date(self) -> None:
response = client.post("/equity-bars", json={"date": "invalid-date"})
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

def test_delete_equity_bars_missing_body(self) -> None:
response = client.request("DELETE", "/equity-bars")
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

def test_delete_equity_bars_invalid_date(self) -> None:
response = client.request(
"DELETE", "/equity-bars", json={"date": "invalid-date"}
)
assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY

@patch("application.datamanager.src.datamanager.main.duckdb")
def test_get_equity_bars_database_error(self, mock_duckdb: MagicMock) -> None:
from duckdb import IOException

mock_connection = MagicMock()
mock_connection.execute.side_effect = IOException("Database error")
mock_duckdb.connect.return_value = mock_connection

mock_settings = MagicMock()
mock_settings.gcp.bucket.name = "test-bucket"

with patch.object(application, "state") as mock_app_state:
mock_app_state.connection = mock_connection
mock_app_state.settings = mock_settings

response = client.get(
"/equity-bars",
params={"start_date": "2023-01-01", "end_date": "2023-01-02"},
)

assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR


if __name__ == "__main__":
unittest.main()
131 changes: 131 additions & 0 deletions application/datamanager/tests/test_datamanager_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import unittest
from datetime import date

import pytest
from pydantic import ValidationError

from application.datamanager.src.datamanager.models import (
BarsSummary,
DateRange,
SummaryDate,
)


class TestSummaryDate(unittest.TestCase):
def test_summary_date_initialization_default(self) -> None:
summary_date = SummaryDate()
assert isinstance(summary_date.date, date)

def test_summary_date_initialization_with_date(self) -> None:
test_date = date(2023, 5, 15)
summary_date = SummaryDate(date=test_date)
assert summary_date.date == test_date

def test_summary_date_string_parsing_iso_format(self) -> None:
summary_date = SummaryDate(date="2023-5-15") # type: ignore
assert summary_date.date == date(2023, 5, 15)

def test_summary_date_string_parsing_slash_format(self) -> None:
summary_date = SummaryDate(date="2023/05/15") # type: ignore
assert summary_date.date == date(2023, 5, 15)

def test_summary_date_invalid_format(self) -> None:
with pytest.raises(ValidationError, match="Invalid date format"):
SummaryDate(date="invalid-date") # type: ignore

def test_summary_date_invalid_date_values(self) -> None:
with pytest.raises(ValidationError):
SummaryDate(date="2023-13-01") # type: ignore

def test_summary_date_json_encoder(self) -> None:
test_date = date(2023, 5, 15)
summary_date = SummaryDate(date=test_date)
json_data = summary_date.model_dump(mode="json")
assert json_data["date"] == "2023/05/15"


class TestDateRange(unittest.TestCase):
def test_date_range_valid(self) -> None:
start_date = date(2023, 1, 1)
end_date = date(2023, 12, 31)
date_range = DateRange(start=start_date, end=end_date)

assert date_range.start == start_date
assert date_range.end == end_date

def test_date_range_same_dates(self) -> None:
same_date = date(2023, 5, 15)
with pytest.raises(ValidationError, match="End date must be after start date"):
DateRange(start=same_date, end=same_date)

def test_date_range_end_before_start(self) -> None:
start_date = date(2023, 12, 31)
end_date = date(2023, 1, 1)
with pytest.raises(ValidationError, match="End date must be after start date"):
DateRange(start=start_date, end=end_date)

def test_date_range_valid_one_day_apart(self) -> None:
start_date = date(2023, 5, 15)
end_date = date(2023, 5, 16)
date_range = DateRange(start=start_date, end=end_date)

assert date_range.start == start_date
assert date_range.end == end_date


class TestBarsSummary(unittest.TestCase):
def test_bars_summary_initialization(self) -> None:
bars_summary = BarsSummary(date="2023-05-15", count=1500)

assert bars_summary.date == "2023-05-15"
assert bars_summary.count == 1500 # noqa: PLR2004

def test_bars_summary_zero_count(self) -> None:
bars_summary = BarsSummary(date="2023-05-15", count=0)

assert bars_summary.date == "2023-05-15"
assert bars_summary.count == 0

def test_bars_summary_negative_count(self) -> None:
bars_summary = BarsSummary(date="2023-05-15", count=-1)

assert bars_summary.date == "2023-05-15"
assert bars_summary.count == -1

def test_bars_summary_json_serialization(self) -> None:
bars_summary = BarsSummary(date="2023-05-15", count=1500)
json_data = bars_summary.model_dump()

assert json_data == {"date": "2023-05-15", "count": 1500}

def test_bars_summary_from_dict(self) -> None:
data = {"date": "2023-05-15", "count": 1500}
bars_summary = BarsSummary.model_validate(data)

assert bars_summary.date == "2023-05-15"
assert bars_summary.count == 1500 # noqa: PLR2004


class TestModelIntegration(unittest.TestCase):
def test_summary_date_to_bars_summary(self) -> None:
summary_date = SummaryDate(date="2023-05-15") # type: ignore
bars_summary = BarsSummary(
date=summary_date.date.strftime("%Y-%m-%d"), count=100
)

assert bars_summary.date == "2023-05-15"
assert bars_summary.count == 100 # noqa: PLR2004

def test_multiple_model_validation(self) -> None:
summary_date = SummaryDate(date="2023-05-15") # type: ignore
date_range = DateRange(start=date(2023, 1, 1), end=date(2023, 12, 31))
bars_summary = BarsSummary(date="2023-05-15", count=1000)

assert summary_date.date == date(2023, 5, 15)
assert date_range.start == date(2023, 1, 1)
assert date_range.end == date(2023, 12, 31)
assert bars_summary.count == 1000 # noqa: PLR2004


if __name__ == "__main__":
unittest.main()
3 changes: 2 additions & 1 deletion application/positionmanager/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name = "positionmanager"
version = "0.1.0"
description = "Position management service"
requires-python = "==3.13"
requires-python = "==3.12.10"
dependencies = [
"fastapi>=0.115.12",
"uvicorn>=0.34.2",
Expand All @@ -14,6 +14,7 @@ dependencies = [
"pyportfolioopt>=1.5.6",
"ecos>=2.0.14",
"prometheus-fastapi-instrumentator>=7.1.0",
"pyarrow>=20.0.0",
]

[tool.hatch.build.targets.wheel]
Expand Down
47 changes: 28 additions & 19 deletions application/positionmanager/src/positionmanager/clients.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Any

import polars as pl
import pyarrow as pa
import requests
from alpaca.trading.client import TradingClient
from alpaca.trading.enums import OrderSide, TimeInForce
Expand All @@ -18,8 +19,8 @@ def __init__(
paper: bool = True,
) -> None:
if not api_key or not api_secret:
msg = "Alpaca API key and secret are required"
raise ValueError(msg)
message = "Alpaca API key and secret are required"
raise ValueError(message)

self.trading_client: TradingClient = TradingClient(
api_key, api_secret, paper=paper
Expand All @@ -30,8 +31,8 @@ def get_cash_balance(self) -> Money:
cash_balance = getattr(account, "cash", None)

if cash_balance is None:
msg = "Cash balance is not available"
raise ValueError(msg)
message = "Cash balance is not available"
raise ValueError(message)

return Money.from_float(float(cash_balance))

Expand Down Expand Up @@ -72,32 +73,40 @@ def get_data(
date_range: DateRange,
) -> pl.DataFrame:
if not self.datamanager_base_url:
msg = "Data manager URL is not configured"
raise ValueError(msg)
message = "Data manager URL is not configured"
raise ValueError(message)

endpoint = f"{self.datamanager_base_url}/equity-bars"

params = {
"start_date": date_range.start.date().isoformat(),
"end_date": date_range.end.date().isoformat(),
}

try:
response = requests.post(endpoint, json=date_range.to_payload(), timeout=10)
response = requests.get(endpoint, params=params, timeout=30)
except requests.RequestException as err:
msg = f"Data manager service call error: {err}"
raise RuntimeError(msg) from err
message = f"Data manager service call error: {err}"
raise RuntimeError(message) from err

response.raise_for_status()
if response.status_code == requests.codes.not_found:
return pl.DataFrame()
if response.status_code != requests.codes.ok:
message = f"Data service error: {response.text}, status code: {response.status_code}" # noqa: E501
raise requests.HTTPError(
message,
)

response_data = response.json()
buffer = pa.py_buffer(response.content)
reader = pa.ipc.RecordBatchStreamReader(buffer)
table = reader.read_all()

data = pl.DataFrame(response_data["data"])
data = pl.DataFrame(pl.from_arrow(table))

data = data.with_columns(
pl.col("timestamp")
.str.slice(0, 10)
.str.strptime(pl.Date, "%Y-%m-%d")
.alias("date"),
)
data = data.with_columns(pl.col("t").cast(pl.Datetime).dt.date().alias("date"))

return (
data.sort("date")
.pivot(on="ticker", index="date", values="close_price")
.pivot(on="T", index="date", values="c")
.with_columns(pl.all().exclude("date").cast(pl.Float64))
)
2 changes: 1 addition & 1 deletion application/positionmanager/src/positionmanager/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

import polars as pl
import requests
from alpaca.common.rest import APIError
from alpaca.common.exceptions import APIError
from fastapi import FastAPI, HTTPException
from prometheus_fastapi_instrumentator import Instrumentator
from pydantic import ValidationError
Expand Down
Loading