feat(PORTFOLIO-001): add position-level portfolio entries
This commit is contained in:
@@ -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"
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user