diff --git a/ft-app/app/main.py b/ft-app/app/main.py index ee0896a..b88e732 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, positions, trades +from app.routers import contracts, admin, auth, positions, trades, option_trades TEMPLATES_DIR = Path(__file__).parent / "templates" @@ -58,6 +58,7 @@ app.include_router(contracts.router) app.include_router(admin.router) app.include_router(positions.router) app.include_router(trades.router) +app.include_router(option_trades.router) @app.get("/") diff --git a/ft-app/app/models.py b/ft-app/app/models.py index 683e7e4..7052cfb 100644 --- a/ft-app/app/models.py +++ b/ft-app/app/models.py @@ -146,4 +146,29 @@ class Trade(Base): result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0) else: result = (self.open_price - self.close_price) * mul - (self.open_fee or 0) - (self.close_fee or 0) + return round(result, 2) + + +class OptionTrade(Base): + __tablename__ = "option_trades" + + id: Mapped[int] = mapped_column(primary_key=True) + product_code: Mapped[str] = mapped_column(String(10)) + contract_code: Mapped[str] = mapped_column(String(10), index=True) + option_type: Mapped[str] = mapped_column(String(4)) + strike_price: Mapped[float] = mapped_column(Float) + open_date: Mapped[date] = mapped_column(Date) + open_price: Mapped[float] = mapped_column(Float) + open_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0) + close_date: Mapped[date | None] = mapped_column(Date, nullable=True) + close_price: Mapped[float | None] = mapped_column(Float, nullable=True) + close_fee: Mapped[float | None] = mapped_column(Float, nullable=True, default=0) + status: Mapped[str] = mapped_column(String(10), default="open") + + @property + def pnl(self) -> float | None: + if self.close_price is None: + return None + mul = 20 # glass futures point value + result = (self.close_price - self.open_price) * mul - (self.open_fee or 0) - (self.close_fee or 0) return round(result, 2) \ No newline at end of file diff --git a/ft-app/app/routers/option_trades.py b/ft-app/app/routers/option_trades.py new file mode 100644 index 0000000..3bfc158 --- /dev/null +++ b/ft-app/app/routers/option_trades.py @@ -0,0 +1,108 @@ +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 OptionTrade, Contract, Product + +router = APIRouter(prefix="/options", tags=["options"]) + +OPTION_TYPES = [("C", "C 看涨"), ("P", "P 看跌")] + + +def get_product_contracts(db: Session) -> dict: + result: dict[str, list[str]] = {} + products = db.query(Product).order_by(Product.code).all() + for p in products: + codes = [c.code for c in p.contracts if c.is_active] + if codes: + result[p.code] = codes + return result + + +def today_str() -> str: + return date.today().isoformat() + + +@router.get("/", response_class=HTMLResponse) +def option_page(request: Request, db: Session = Depends(get_db)): + view = request.query_params.get("view", "") + open_trades = ( + db.query(OptionTrade).filter(OptionTrade.status == "open") + .order_by(OptionTrade.open_date.desc()).all() + ) + closed_trades = ( + db.query(OptionTrade).filter(OptionTrade.status == "closed") + .order_by(OptionTrade.close_date.desc()).limit(50).all() + ) + + template = request.app.state.templates.get_template("options.html") + return HTMLResponse( + template.render( + request=request, + active_nav="options", + product_contracts=get_product_contracts(db), + option_types=OPTION_TYPES, + open_trades=open_trades, + closed_trades=closed_trades, + today=today_str(), + ) + ) + + +@router.post("/open") +def open_trade( + request: Request, + contract_code: str = Form(...), + option_type: str = Form(...), + strike_price: float = Form(...), + open_date: str = Form(...), + open_price: float = Form(...), + open_fee: float = Form(0.0), + db: Session = Depends(get_db), +): + code = contract_code.upper() + contract = db.query(Contract).filter(Contract.code == code).first() + product_code = contract.product.code if contract else code[:2] + + t = OptionTrade( + product_code=product_code, + contract_code=code, + option_type=option_type, + strike_price=strike_price, + open_date=date.fromisoformat(open_date), + open_price=open_price, + open_fee=open_fee, + status="open", + ) + db.add(t) + db.commit() + return RedirectResponse("/options/", status_code=303) + + +@router.post("/{trade_id}/close") +def close_trade( + request: Request, + trade_id: int, + close_date: str = Form(...), + close_price: float = Form(...), + close_fee: float = Form(0.0), + db: Session = Depends(get_db), +): + t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first() + if t and t.status == "open": + t.close_date = date.fromisoformat(close_date) + t.close_price = close_price + t.close_fee = close_fee + t.status = "closed" + db.commit() + return RedirectResponse("/options/", status_code=303) + + +@router.post("/{trade_id}/delete") +def delete_trade(trade_id: int, db: Session = Depends(get_db)): + t = db.query(OptionTrade).filter(OptionTrade.id == trade_id).first() + if t: + db.delete(t) + db.commit() + return RedirectResponse("/options/", status_code=303) diff --git a/ft-app/app/templates/base.html b/ft-app/app/templates/base.html index 99767f5..3616294 100644 --- a/ft-app/app/templates/base.html +++ b/ft-app/app/templates/base.html @@ -209,6 +209,9 @@ 📝 交易记录 + + 📊 期权交易 + ⚙️ 系统管理 diff --git a/ft-app/app/templates/options.html b/ft-app/app/templates/options.html new file mode 100644 index 0000000..2cd85eb --- /dev/null +++ b/ft-app/app/templates/options.html @@ -0,0 +1,236 @@ +{% extends "base.html" %} +{% block title %}期权交易{% endblock %} +{% block heading %}期权交易{% endblock %} +{% block breadcrumb %}期权开平仓记录{% endblock %} + +{% block content %} + +{% set view = request.query_params.get('view', '') %} + + + +
+ 📋 持仓 + 📊 已平仓 +
+ +{% if view != 'closed' %} +
+ +{# ── 新建开仓 ── #} +
新建开仓
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ +{# ── 持仓列表 ── #} +
持仓中 · {{ open_trades|length }} 笔
+ +{% if open_trades %} +
+ + + {% for t in open_trades %} + + + + + + + + + + + {% endfor %} +
品种合约类型行权价权利金开仓日期手续费操作
{{ t.product_code }}{{ t.contract_code.replace(t.product_code, '', 1) }} + {% if t.option_type == 'C' %} + C 看涨 + {% else %} + P 看跌 + {% endif %} + {{ t.strike_price }}{{ t.open_price }}{{ t.open_date }}{{ t.open_fee or 0 }} + +
+ +
+
+
+{% else %} +
暂无持仓
+{% endif %} + +{# ── 平仓弹窗 ── #} + + + +
+{% else %} +
+{# ═══════════════ 已平仓 ═══════════════ #} + +{% if closed_trades %} +
已平仓 · {{ closed_trades|length }} 笔
+
+ + + {% for t in closed_trades %} + + + + + + + + + + + + + + + {% endfor %} +
品种合约类型行权价开仓平仓权利金平仓价开仓费平仓费盈亏操作
{{ t.product_code }}{{ t.contract_code.replace(t.product_code, '', 1) }} + {% if t.option_type == 'C' %} + C 看涨 + {% else %} + P 看跌 + {% endif %} + {{ t.strike_price }}{{ t.open_date }}{{ t.close_date }}{{ t.open_price }}{{ t.close_price }}{{ t.open_fee or 0 }}{{ t.close_fee or 0 }} + {% set p = t.pnl %} + {% if p is not none %} + + {% if p > 0 %}+{% endif %}{{ p }} + + {% endif %} + +
+ +
+
+
+ +{% set ns = namespace(total_pnl=0, total_open_fee=0, total_close_fee=0) %} +{% for t in closed_trades %} + {% set ns.total_pnl = ns.total_pnl + (t.pnl or 0) %} + {% set ns.total_open_fee = ns.total_open_fee + (t.open_fee or 0) %} + {% set ns.total_close_fee = ns.total_close_fee + (t.close_fee or 0) %} +{% endfor %} +
+ 盈亏合计 {% if ns.total_pnl > 0 %}+{% endif %}{{ '%.2f'|format(ns.total_pnl) }} + | + 开仓手续费 {{ '%.2f'|format(ns.total_open_fee) }} + | + 平仓手续费 {{ '%.2f'|format(ns.total_close_fee) }} + | + 手续费合计 {{ '%.2f'|format(ns.total_open_fee + ns.total_close_fee) }} +
+{% else %} +
暂无已平仓记录
+{% endif %} + +
+{% endif %} + +{% if view != 'closed' %} + +{% endif %} +{% endblock %}