diff --git a/ft-app/app/main.py b/ft-app/app/main.py index ddd530a..e2915c5 100644 --- a/ft-app/app/main.py +++ b/ft-app/app/main.py @@ -7,7 +7,7 @@ from starlette.middleware.base import BaseHTTPMiddleware from app.database import engine, Base, SessionLocal from app.models import User from app.seed import seed -from app.routers import contracts, admin, auth +from app.routers import contracts, admin, auth, positions TEMPLATES_DIR = Path(__file__).parent / "templates" @@ -56,6 +56,7 @@ app.add_middleware(AuthMiddleware) app.include_router(auth.router) app.include_router(contracts.router) app.include_router(admin.router) +app.include_router(positions.router) @app.get("/") diff --git a/ft-app/app/models.py b/ft-app/app/models.py index bc0802d..45a1eb8 100644 --- a/ft-app/app/models.py +++ b/ft-app/app/models.py @@ -86,4 +86,37 @@ class User(Base): dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 100000) return dk.hex() == dk_hex except (ValueError, AttributeError): - return False \ No newline at end of file + return False + + +class Round(Base): + __tablename__ = "rounds" + + id: Mapped[int] = mapped_column(primary_key=True) + contract_code: Mapped[str] = mapped_column(String(10), index=True) + daily_hands: Mapped[int] = mapped_column(Integer, default=1) + status: Mapped[str] = mapped_column(String(20), default="active") + started_at: Mapped[date] = mapped_column(Date) + + positions: Mapped[list["Position"]] = relationship( + back_populates="round", cascade="all, delete-orphan" + ) + + @property + def total_locks(self) -> int: + return sum(p.locked_count for p in self.positions) + + +class Position(Base): + __tablename__ = "positions" + + id: Mapped[int] = mapped_column(primary_key=True) + round_id: Mapped[int] = mapped_column(ForeignKey("rounds.id"), index=True) + open_price: Mapped[int] = mapped_column(Integer) + amp_threshold: Mapped[int] = mapped_column(Integer) + lock_price: Mapped[int] = mapped_column(Integer) + hands: Mapped[int] = mapped_column(Integer, default=1) + locked_count: Mapped[int] = mapped_column(Integer, default=0) + opened_at: Mapped[date] = mapped_column(Date) + + round: Mapped["Round"] = relationship(back_populates="positions") \ No newline at end of file diff --git a/ft-app/app/routers/positions.py b/ft-app/app/routers/positions.py new file mode 100644 index 0000000..722a6ab --- /dev/null +++ b/ft-app/app/routers/positions.py @@ -0,0 +1,117 @@ +from datetime import date +from fastapi import APIRouter, Depends, Form, Request +from fastapi.responses import HTMLResponse, RedirectResponse +from sqlalchemy.orm import Session +from app.database import get_db +from app.models import Round, Position, Contract + +router = APIRouter(prefix="/positions", tags=["positions"]) + + +def get_active_contracts(db: Session) -> list[str]: + contracts = ( + db.query(Contract.code) + .filter(Contract.is_active == True) + .order_by(Contract.code) + .all() + ) + return [c[0] for c in contracts] + + +@router.get("/", response_class=HTMLResponse) +def positions_page(request: Request, db: Session = Depends(get_db)): + active_round = ( + db.query(Round) + .filter(Round.status == "active") + .order_by(Round.started_at.desc()) + .first() + ) + + template = request.app.state.templates.get_template("positions.html") + return HTMLResponse( + template.render( + request=request, + active_nav="positions", + contracts=get_active_contracts(db), + active_round=active_round, + ) + ) + + +@router.post("/round") +def create_round( + request: Request, + contract_code: str = Form(...), + daily_hands: int = Form(1), + db: Session = Depends(get_db), +): + existing = db.query(Round).filter(Round.status == "active").first() + if existing: + return RedirectResponse("/positions/?error=已有活跃轮次", status_code=303) + + r = Round( + contract_code=contract_code.upper(), + daily_hands=daily_hands, + status="active", + started_at=date.today(), + ) + db.add(r) + db.commit() + return RedirectResponse("/positions/", status_code=303) + + +@router.post("/add") +def add_position( + request: Request, + open_price: int = Form(...), + amp_threshold: int = Form(...), + hands: int = Form(1), + db: Session = Depends(get_db), +): + active_round = db.query(Round).filter(Round.status == "active").first() + if not active_round: + return RedirectResponse("/positions/?error=无活跃轮次", status_code=303) + + lock_price = open_price + amp_threshold + p = Position( + round_id=active_round.id, + open_price=open_price, + amp_threshold=amp_threshold, + lock_price=lock_price, + hands=hands, + opened_at=date.today(), + ) + db.add(p) + db.commit() + return RedirectResponse("/positions/", status_code=303) + + +@router.post("/{position_id}/lock") +def lock_position(position_id: int, db: Session = Depends(get_db)): + p = db.query(Position).filter(Position.id == position_id).first() + if p and p.locked_count < p.hands: + p.locked_count += 1 + db.commit() + return RedirectResponse("/positions/", status_code=303) + + +@router.post("/{position_id}/unlock") +def unlock_position(position_id: int, db: Session = Depends(get_db)): + p = db.query(Position).filter(Position.id == position_id).first() + if p and p.locked_count > 0: + p.locked_count -= 1 + db.commit() + return RedirectResponse("/positions/", status_code=303) + + +@router.post("/close") +def close_round( + request: Request, + result: str = Form(...), + db: Session = Depends(get_db), +): + active_round = db.query(Round).filter(Round.status == "active").first() + if active_round: + active_round.status = result + db.commit() + return RedirectResponse("/positions/", status_code=303) diff --git a/ft-app/app/templates/base.html b/ft-app/app/templates/base.html index 8c3e13e..16f213f 100644 --- a/ft-app/app/templates/base.html +++ b/ft-app/app/templates/base.html @@ -59,6 +59,12 @@ border-left-color: var(--accent); font-weight: 600; } .sidebar-nav .icon { font-size: 1.1rem; width: 22px; text-align: center; } + .nav-parent { position: relative; } + .nav-arrow { font-size: 0.65rem; margin-left: auto; transition: transform .2s; color: var(--sub); } + .nav-group.open .nav-arrow { transform: rotate(-180deg); } + .nav-children { display: none; } + .nav-group.open .nav-children { display: block; } + .nav-children a { padding-left: 48px !important; font-size: 0.84rem !important; } .sidebar-footer { padding: 16px 20px; border-top: 1px solid var(--border); } @@ -195,9 +201,20 @@