feat(PORTFOLIO-001): add position-level portfolio entries

This commit is contained in:
Bu5hm4nn
2026-03-28 21:29:30 +01:00
parent 447f4bbd0d
commit 1a39956757
6 changed files with 1041 additions and 7 deletions

View File

@@ -1,11 +1,15 @@
from __future__ import annotations
import logging
from datetime import date
from decimal import Decimal
from uuid import uuid4
from fastapi.responses import RedirectResponse
from nicegui import ui
from app.models.portfolio import PortfolioConfig
from app.models.position import Position
from app.models.workspace import get_workspace_repository
from app.pages.common import dashboard_page, split_page_panes
from app.services.alerts import AlertService, build_portfolio_alert_context
@@ -270,6 +274,154 @@ def settings_page(workspace_id: str) -> None:
step=1,
).classes("w-full")
# Position Management Card
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):
ui.label("Portfolio Positions").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(
"Manage individual position entries. Each position tracks its own entry date and price."
).classes("text-sm text-slate-500 dark:text-slate-400")
# Position list container
position_list_container = ui.column().classes("w-full gap-2 mt-3")
# Add position form (hidden by default)
with (
ui.dialog() as add_position_dialog,
ui.card().classes(
"w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900"
),
):
ui.label("Add New Position").classes(
"text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4"
)
pos_underlying = ui.select(
{
"GLD": "SPDR Gold Shares ETF",
"XAU": "Physical Gold (oz)",
"GC=F": "Gold Futures",
},
value="GLD",
label="Underlying",
).classes("w-full")
pos_quantity = ui.number(
"Quantity",
value=100.0,
min=0.0001,
step=0.01,
).classes("w-full")
pos_unit = ui.select(
{"oz": "Troy Ounces", "shares": "Shares", "g": "Grams", "contracts": "Contracts"},
value="oz",
label="Unit",
).classes("w-full")
pos_entry_price = ui.number(
"Entry Price ($/unit)",
value=2150.0,
min=0.01,
step=0.01,
).classes("w-full")
with ui.row().classes("w-full items-center gap-2"):
ui.label("Entry Date").classes("text-sm font-medium")
pos_entry_date = (
ui.date(
value=date.today().isoformat(),
)
.classes("w-full")
.props("stack-label")
)
pos_notes = ui.textarea(
label="Notes (optional)",
placeholder="Add notes about this position...",
).classes("w-full")
with ui.row().classes("w-full gap-3 mt-4"):
ui.button("Cancel", on_click=lambda: add_position_dialog.close()).props("outline")
ui.button("Add Position", on_click=lambda: add_position_from_form()).props("color=primary")
def add_position_from_form() -> None:
"""Add a new position from the form."""
try:
new_position = Position(
id=uuid4(),
underlying=str(pos_underlying.value),
quantity=Decimal(str(pos_quantity.value)),
unit=str(pos_unit.value),
entry_price=Decimal(str(pos_entry_price.value)),
entry_date=date.fromisoformat(str(pos_entry_date.value)),
entry_basis_mode="weight",
notes=str(pos_notes.value or ""),
)
workspace_repo.add_position(workspace_id, new_position)
add_position_dialog.close()
render_positions()
ui.notify("Position added successfully", color="positive")
except Exception as e:
logger.exception("Failed to add position")
ui.notify(f"Failed to add position: {e}", color="negative")
def render_positions() -> None:
"""Render the list of positions."""
position_list_container.clear()
positions = workspace_repo.list_positions(workspace_id)
if not positions:
with position_list_container:
ui.label("No positions yet. Click 'Add Position' to create one.").classes(
"text-sm text-slate-500 dark:text-slate-400 italic"
)
return
for pos in positions:
with ui.card().classes(
"w-full rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-800"
):
with ui.row().classes("w-full items-start justify-between gap-3"):
with ui.column().classes("gap-1"):
ui.label(f"{pos.underlying} · {float(pos.quantity):,.4f} {pos.unit}").classes(
"text-sm font-medium text-slate-900 dark:text-slate-100"
)
ui.label(
f"Entry: ${float(pos.entry_price):,.2f}/{pos.unit} · Date: {pos.entry_date}"
).classes("text-xs text-slate-500 dark:text-slate-400")
if pos.notes:
ui.label(pos.notes).classes("text-xs text-slate-500 dark:text-slate-400 italic")
ui.label(f"Value: ${float(pos.entry_value):,.2f}").classes(
"text-xs font-semibold text-emerald-600 dark:text-emerald-400"
)
with ui.row().classes("gap-1"):
ui.button(
icon="delete",
on_click=lambda p=pos: remove_position(p.id),
).props(
"flat dense color=negative size=sm"
).classes("self-start")
def remove_position(position_id) -> None:
"""Remove a position."""
try:
workspace_repo.remove_position(workspace_id, position_id)
render_positions()
ui.notify("Position removed", color="positive")
except Exception as e:
logger.exception("Failed to remove position")
ui.notify(f"Failed to remove position: {e}", color="negative")
with ui.row().classes("w-full mt-3"):
ui.button("Add Position", icon="add", on_click=lambda: add_position_dialog.open()).props(
"color=primary"
)
# Initial render
render_positions()
with ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
):