diff --git a/.gitignore b/.gitignore index 63fd5cf61..26f8fd3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ .venv/ __pycache__/ .envrc +.env.nu coverage/ .coverage *.csv diff --git a/applications/portfoliomanager/src/portfoliomanager/alpaca_client.py b/applications/portfoliomanager/src/portfoliomanager/alpaca_client.py index 5ab4be2f8..1007b6daa 100644 --- a/applications/portfoliomanager/src/portfoliomanager/alpaca_client.py +++ b/applications/portfoliomanager/src/portfoliomanager/alpaca_client.py @@ -160,12 +160,46 @@ def open_position( def close_position( self, ticker: str, - ) -> None: - self.trading_client.close_position( - symbol_or_asset_id=ticker.upper(), - close_options=ClosePositionRequest( - percentage="100", - ), - ) + ) -> bool: + """Close a position for the given ticker. - time.sleep(self.rate_limit_sleep) + Returns True if position was closed, False if position didn't exist. + """ + try: + self.trading_client.close_position( + symbol_or_asset_id=ticker.upper(), + close_options=ClosePositionRequest( + percentage="100", + ), + ) + time.sleep(self.rate_limit_sleep) + except APIError as e: + # Prefer structured information from the Alpaca API when available, + # and fall back to matching documented error message fragments for + # backwards compatibility. + status_code = getattr(e, "status_code", None) + error_code = getattr(e, "code", None) + error_message = getattr(e, "message", None) + error_str = ( + str(error_message) if error_message is not None else str(e) + ).lower() + + # Known Alpaca behaviours when closing a non-existent position: + # - HTTP 404 Not Found + # - Specific error_code values (e.g. "position_not_found") + # - Error messages containing "position not found" + http_not_found = 404 + position_not_found = ( + status_code == http_not_found + or error_code in {"position_not_found"} + or "position not found" in error_str + or "position does not exist" in error_str + ) + if position_not_found: + logger.info( + "Position already closed or does not exist", + ticker=ticker, + ) + return False + raise + return True diff --git a/applications/portfoliomanager/src/portfoliomanager/risk_management.py b/applications/portfoliomanager/src/portfoliomanager/risk_management.py index fa2aa928c..66ce8b7f7 100644 --- a/applications/portfoliomanager/src/portfoliomanager/risk_management.py +++ b/applications/portfoliomanager/src/portfoliomanager/risk_management.py @@ -83,6 +83,18 @@ def add_portfolio_performance_columns( prior_predictions = prior_predictions.clone() prior_equity_bars = prior_equity_bars.clone() + # Ensure timestamp columns have matching types for joins and comparisons. + # Timestamps may arrive as i64 (from JSON integer serialization) or f64 (from + # Python float conversion). Unconditional casting to Float64 is simpler and + # more robust than checking dtypes, and the performance cost is negligible. + prior_portfolio = prior_portfolio.with_columns(pl.col("timestamp").cast(pl.Float64)) + prior_predictions = prior_predictions.with_columns( + pl.col("timestamp").cast(pl.Float64) + ) + prior_equity_bars = prior_equity_bars.with_columns( + pl.col("timestamp").cast(pl.Float64) + ) + prior_portfolio_predictions = prior_portfolio.join( other=prior_predictions, on=["ticker", "timestamp"], diff --git a/applications/portfoliomanager/src/portfoliomanager/server.py b/applications/portfoliomanager/src/portfoliomanager/server.py index 4cbb36c33..baae81efb 100644 --- a/applications/portfoliomanager/src/portfoliomanager/server.py +++ b/applications/portfoliomanager/src/portfoliomanager/server.py @@ -173,17 +173,31 @@ async def create_portfolio() -> Response: # noqa: PLR0911, PLR0912, PLR0915, C9 close_results = [] for close_position in close_positions: try: - alpaca_client.close_position( + was_closed = alpaca_client.close_position( ticker=close_position["ticker"], ) - logger.info("Closed position", ticker=close_position["ticker"]) - close_results.append( - { - "ticker": close_position["ticker"], - "action": "close", - "status": "success", - } - ) + if was_closed: + logger.info("Closed position", ticker=close_position["ticker"]) + close_results.append( + { + "ticker": close_position["ticker"], + "action": "close", + "status": "success", + } + ) + else: + logger.info( + "Position already closed or did not exist", + ticker=close_position["ticker"], + ) + close_results.append( + { + "ticker": close_position["ticker"], + "action": "close", + "status": "skipped", + "reason": "position_not_found", + } + ) except Exception as e: logger.exception( "Failed to close position", diff --git a/tools/sync_equity_categories.py b/tools/sync_equity_categories.py index 5c988c8bb..e4a6184b1 100644 --- a/tools/sync_equity_categories.py +++ b/tools/sync_equity_categories.py @@ -73,6 +73,9 @@ def extract_categories(tickers: list[dict]) -> pl.DataFrame: rows = [] for ticker_data in tickers: ticker = ticker_data.get("ticker", "") + # Skip entries with empty or missing ticker values + if not ticker: + continue # Filter for Common Stock and all ADR types if ticker_data.get("type") not in EQUITY_TYPES: continue