742 lines
28 KiB
Python
742 lines
28 KiB
Python
"""
|
|
FinanceIQ v5.2 — Enhanced Paper Trading Portfolio Engine
|
|
SQLite-backed virtual portfolio with $100K starting capital.
|
|
|
|
Features inspired by hftbacktest:
|
|
- Market, Limit, Stop-Loss, Take-Profit orders
|
|
- Slippage & commission modeling
|
|
- Short selling with margin tracking
|
|
- Equity curve tracking
|
|
- Enhanced analytics (Sortino, Calmar, profit factor, avg hold period)
|
|
"""
|
|
import sqlite3
|
|
import os
|
|
import math
|
|
from datetime import datetime
|
|
|
|
DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "portfolio.db")
|
|
INITIAL_CASH = 100000.00
|
|
DEFAULT_SLIPPAGE_BPS = 5 # 0.05% default slippage
|
|
DEFAULT_COMMISSION_PER_SHARE = 0.005 # $0.005 per share (IBKR-style)
|
|
MIN_COMMISSION = 1.00 # $1 minimum per trade
|
|
|
|
|
|
def get_db():
|
|
"""Get database connection and ensure tables exist."""
|
|
conn = sqlite3.connect(DB_PATH)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS portfolio_meta (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
cash REAL NOT NULL DEFAULT 100000.00,
|
|
slippage_bps REAL NOT NULL DEFAULT 5,
|
|
commission_per_share REAL NOT NULL DEFAULT 0.005,
|
|
margin_used REAL NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS positions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ticker TEXT NOT NULL,
|
|
asset_type TEXT NOT NULL DEFAULT 'stock',
|
|
shares REAL NOT NULL DEFAULT 0,
|
|
avg_cost REAL NOT NULL DEFAULT 0,
|
|
side TEXT NOT NULL DEFAULT 'LONG',
|
|
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
UNIQUE(ticker, side)
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS transactions (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ticker TEXT NOT NULL,
|
|
asset_type TEXT NOT NULL DEFAULT 'stock',
|
|
action TEXT NOT NULL,
|
|
side TEXT NOT NULL DEFAULT 'LONG',
|
|
shares REAL NOT NULL,
|
|
price REAL NOT NULL,
|
|
slippage REAL NOT NULL DEFAULT 0,
|
|
commission REAL NOT NULL DEFAULT 0,
|
|
total REAL NOT NULL,
|
|
pnl REAL DEFAULT NULL,
|
|
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS pending_orders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ticker TEXT NOT NULL,
|
|
asset_type TEXT NOT NULL DEFAULT 'stock',
|
|
order_type TEXT NOT NULL,
|
|
side TEXT NOT NULL DEFAULT 'BUY',
|
|
shares REAL NOT NULL,
|
|
target_price REAL NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'PENDING',
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
filled_at TEXT DEFAULT NULL,
|
|
expires_at TEXT DEFAULT NULL
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS equity_curve (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
timestamp TEXT NOT NULL,
|
|
total_value REAL NOT NULL,
|
|
cash REAL NOT NULL,
|
|
positions_value REAL NOT NULL,
|
|
daily_return REAL DEFAULT 0
|
|
)
|
|
""")
|
|
conn.execute("""
|
|
CREATE TABLE IF NOT EXISTS portfolio_snapshots (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
date TEXT NOT NULL UNIQUE,
|
|
total_value REAL NOT NULL,
|
|
cash REAL NOT NULL,
|
|
positions_value REAL NOT NULL
|
|
)
|
|
""")
|
|
|
|
cursor = conn.execute("SELECT COUNT(*) FROM portfolio_meta")
|
|
if cursor.fetchone()[0] == 0:
|
|
conn.execute("INSERT INTO portfolio_meta (id, cash) VALUES (1, ?)", (INITIAL_CASH,))
|
|
conn.commit()
|
|
return conn
|
|
|
|
|
|
def _get_meta(conn):
|
|
row = conn.execute("SELECT * FROM portfolio_meta WHERE id = 1").fetchone()
|
|
return dict(row)
|
|
|
|
|
|
def _calc_slippage(price, shares, slippage_bps, side):
|
|
"""Calculate slippage based on order direction."""
|
|
slip_pct = slippage_bps / 10000.0
|
|
if side == "BUY":
|
|
return price * slip_pct # pay more when buying
|
|
else:
|
|
return -price * slip_pct # receive less when selling
|
|
|
|
|
|
def _calc_commission(shares, comm_per_share):
|
|
"""Calculate commission with minimum."""
|
|
return max(abs(shares) * comm_per_share, MIN_COMMISSION)
|
|
|
|
|
|
def get_cash():
|
|
conn = get_db()
|
|
row = conn.execute("SELECT cash FROM portfolio_meta WHERE id = 1").fetchone()
|
|
conn.close()
|
|
return row["cash"] if row else INITIAL_CASH
|
|
|
|
|
|
def get_positions():
|
|
conn = get_db()
|
|
rows = conn.execute("SELECT * FROM positions WHERE shares > 0").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def buy(ticker, shares, price, asset_type="stock"):
|
|
"""Buy shares (long) with slippage and commission."""
|
|
if shares <= 0 or price <= 0:
|
|
return {"error": "Invalid shares or price"}
|
|
|
|
conn = get_db()
|
|
meta = _get_meta(conn)
|
|
cash = meta["cash"]
|
|
slippage_bps = meta["slippage_bps"]
|
|
comm_per_share = meta["commission_per_share"]
|
|
|
|
# Apply slippage
|
|
slip = _calc_slippage(price, shares, slippage_bps, "BUY")
|
|
exec_price = price + slip
|
|
|
|
# Calculate commission
|
|
commission = _calc_commission(shares, comm_per_share)
|
|
|
|
total_cost = shares * exec_price + commission
|
|
|
|
if total_cost > cash:
|
|
conn.close()
|
|
return {"error": f"Insufficient cash. Need ${total_cost:.2f}, have ${cash:.2f}"}
|
|
|
|
new_cash = cash - total_cost
|
|
conn.execute("UPDATE portfolio_meta SET cash = ?, updated_at = datetime('now') WHERE id = 1", (new_cash,))
|
|
|
|
existing = conn.execute("SELECT * FROM positions WHERE ticker = ? AND side = 'LONG'", (ticker,)).fetchone()
|
|
if existing:
|
|
old_shares = existing["shares"]
|
|
old_avg = existing["avg_cost"]
|
|
new_shares = old_shares + shares
|
|
new_avg = ((old_shares * old_avg) + (shares * exec_price)) / new_shares
|
|
conn.execute(
|
|
"UPDATE positions SET shares = ?, avg_cost = ?, updated_at = datetime('now') WHERE ticker = ? AND side = 'LONG'",
|
|
(new_shares, new_avg, ticker)
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"INSERT INTO positions (ticker, asset_type, shares, avg_cost, side) VALUES (?, ?, ?, ?, 'LONG')",
|
|
(ticker, asset_type, shares, exec_price)
|
|
)
|
|
|
|
conn.execute(
|
|
"""INSERT INTO transactions (ticker, asset_type, action, side, shares, price, slippage, commission, total)
|
|
VALUES (?, ?, 'BUY', 'LONG', ?, ?, ?, ?, ?)""",
|
|
(ticker, asset_type, shares, exec_price, slip * shares, commission, total_cost)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return {
|
|
"success": True, "action": "BUY", "ticker": ticker,
|
|
"shares": shares, "price": round(exec_price, 4),
|
|
"slippage": round(slip, 4), "commission": round(commission, 2),
|
|
"total": round(total_cost, 2), "remaining_cash": round(new_cash, 2)
|
|
}
|
|
|
|
|
|
def sell(ticker, shares, price):
|
|
"""Sell long shares with slippage and commission."""
|
|
if shares <= 0 or price <= 0:
|
|
return {"error": "Invalid shares or price"}
|
|
|
|
conn = get_db()
|
|
meta = _get_meta(conn)
|
|
slippage_bps = meta["slippage_bps"]
|
|
comm_per_share = meta["commission_per_share"]
|
|
|
|
existing = conn.execute("SELECT * FROM positions WHERE ticker = ? AND side = 'LONG'", (ticker,)).fetchone()
|
|
if not existing or existing["shares"] < shares:
|
|
conn.close()
|
|
available = existing["shares"] if existing else 0
|
|
return {"error": f"Insufficient shares. Have {available}, trying to sell {shares}"}
|
|
|
|
slip = _calc_slippage(price, shares, slippage_bps, "SELL")
|
|
exec_price = price + slip # slip is negative for sells
|
|
commission = _calc_commission(shares, comm_per_share)
|
|
total_proceeds = shares * exec_price - commission
|
|
|
|
new_shares = existing["shares"] - shares
|
|
cash = meta["cash"]
|
|
new_cash = cash + total_proceeds
|
|
|
|
conn.execute("UPDATE portfolio_meta SET cash = ?, updated_at = datetime('now') WHERE id = 1", (new_cash,))
|
|
|
|
if new_shares <= 0:
|
|
conn.execute("DELETE FROM positions WHERE ticker = ? AND side = 'LONG'", (ticker,))
|
|
else:
|
|
conn.execute(
|
|
"UPDATE positions SET shares = ?, updated_at = datetime('now') WHERE ticker = ? AND side = 'LONG'",
|
|
(new_shares, ticker)
|
|
)
|
|
|
|
pnl = (exec_price - existing["avg_cost"]) * shares - commission
|
|
conn.execute(
|
|
"""INSERT INTO transactions (ticker, asset_type, action, side, shares, price, slippage, commission, total, pnl)
|
|
VALUES (?, ?, 'SELL', 'LONG', ?, ?, ?, ?, ?, ?)""",
|
|
(ticker, existing["asset_type"], shares, exec_price, slip * shares, commission, total_proceeds, pnl)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return {
|
|
"success": True, "action": "SELL", "ticker": ticker,
|
|
"shares": shares, "price": round(exec_price, 4),
|
|
"slippage": round(slip, 4), "commission": round(commission, 2),
|
|
"total": round(total_proceeds, 2), "pnl": round(pnl, 2),
|
|
"remaining_cash": round(new_cash, 2)
|
|
}
|
|
|
|
|
|
def short_sell(ticker, shares, price, asset_type="stock"):
|
|
"""Open a short position. Margin is deducted from cash."""
|
|
if shares <= 0 or price <= 0:
|
|
return {"error": "Invalid shares or price"}
|
|
|
|
conn = get_db()
|
|
meta = _get_meta(conn)
|
|
cash = meta["cash"]
|
|
slippage_bps = meta["slippage_bps"]
|
|
comm_per_share = meta["commission_per_share"]
|
|
|
|
slip = _calc_slippage(price, shares, slippage_bps, "SELL")
|
|
exec_price = price + slip # worse price when selling (slippage)
|
|
commission = _calc_commission(shares, comm_per_share)
|
|
|
|
# Margin requirement: 100% of position value + commission
|
|
margin_req = shares * exec_price + commission
|
|
if margin_req > cash:
|
|
conn.close()
|
|
return {"error": f"Insufficient funds. Need ${margin_req:.2f}, have ${cash:.2f}"}
|
|
|
|
# Deduct margin from cash (no proceeds added — paper trading model)
|
|
new_cash = cash - margin_req
|
|
new_margin = meta["margin_used"] + (shares * exec_price)
|
|
|
|
conn.execute("UPDATE portfolio_meta SET cash = ?, margin_used = ?, updated_at = datetime('now') WHERE id = 1",
|
|
(new_cash, new_margin))
|
|
|
|
existing = conn.execute("SELECT * FROM positions WHERE ticker = ? AND side = 'SHORT'", (ticker,)).fetchone()
|
|
if existing:
|
|
old_shares = existing["shares"]
|
|
old_avg = existing["avg_cost"]
|
|
new_shares = old_shares + shares
|
|
new_avg = ((old_shares * old_avg) + (shares * exec_price)) / new_shares
|
|
conn.execute(
|
|
"UPDATE positions SET shares = ?, avg_cost = ?, updated_at = datetime('now') WHERE ticker = ? AND side = 'SHORT'",
|
|
(new_shares, new_avg, ticker)
|
|
)
|
|
else:
|
|
conn.execute(
|
|
"INSERT INTO positions (ticker, asset_type, shares, avg_cost, side) VALUES (?, ?, ?, ?, 'SHORT')",
|
|
(ticker, asset_type, shares, exec_price)
|
|
)
|
|
|
|
conn.execute(
|
|
"""INSERT INTO transactions (ticker, asset_type, action, side, shares, price, slippage, commission, total)
|
|
VALUES (?, ?, 'SHORT', 'SHORT', ?, ?, ?, ?, ?)""",
|
|
(ticker, asset_type, shares, exec_price, abs(slip) * shares, commission, margin_req)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return {
|
|
"success": True, "action": "SHORT", "ticker": ticker,
|
|
"shares": shares, "price": round(exec_price, 4),
|
|
"margin_required": round(margin_req, 2),
|
|
"commission": round(commission, 2)
|
|
}
|
|
|
|
|
|
def cover_short(ticker, shares, price):
|
|
"""Cover (buy back) a short position. Releases margin + P&L to cash."""
|
|
if shares <= 0 or price <= 0:
|
|
return {"error": "Invalid shares or price"}
|
|
|
|
conn = get_db()
|
|
meta = _get_meta(conn)
|
|
slippage_bps = meta["slippage_bps"]
|
|
comm_per_share = meta["commission_per_share"]
|
|
|
|
existing = conn.execute("SELECT * FROM positions WHERE ticker = ? AND side = 'SHORT'", (ticker,)).fetchone()
|
|
if not existing or existing["shares"] < shares:
|
|
conn.close()
|
|
available = existing["shares"] if existing else 0
|
|
return {"error": f"No short position. Have {available} short shares."}
|
|
|
|
slip = _calc_slippage(price, shares, slippage_bps, "BUY")
|
|
exec_price = price + slip # worse price when buying back (slippage)
|
|
commission = _calc_commission(shares, comm_per_share)
|
|
|
|
# P&L: profit when we shorted high and cover low
|
|
entry_value = shares * existing["avg_cost"]
|
|
cover_cost = shares * exec_price
|
|
pnl = entry_value - cover_cost - commission # short profit when price drops
|
|
|
|
# Release margin and return to cash with P&L
|
|
cash = meta["cash"]
|
|
margin_release = entry_value # we held 100% of entry value as margin
|
|
new_cash = cash + margin_release + pnl # margin back + profit/loss
|
|
new_margin = max(0, meta["margin_used"] - margin_release)
|
|
|
|
conn.execute("UPDATE portfolio_meta SET cash = ?, margin_used = ?, updated_at = datetime('now') WHERE id = 1",
|
|
(new_cash, new_margin))
|
|
|
|
new_shares = existing["shares"] - shares
|
|
if new_shares <= 0:
|
|
conn.execute("DELETE FROM positions WHERE ticker = ? AND side = 'SHORT'", (ticker,))
|
|
else:
|
|
conn.execute(
|
|
"UPDATE positions SET shares = ?, updated_at = datetime('now') WHERE ticker = ? AND side = 'SHORT'",
|
|
(new_shares, ticker)
|
|
)
|
|
|
|
conn.execute(
|
|
"""INSERT INTO transactions (ticker, asset_type, action, side, shares, price, slippage, commission, total, pnl)
|
|
VALUES (?, ?, 'COVER', 'SHORT', ?, ?, ?, ?, ?, ?)""",
|
|
(ticker, existing["asset_type"], shares, exec_price, abs(slip) * shares, commission, cover_cost + commission, pnl)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
return {
|
|
"success": True, "action": "COVER", "ticker": ticker,
|
|
"shares": shares, "price": round(exec_price, 4),
|
|
"pnl": round(pnl, 2), "commission": round(commission, 2),
|
|
"remaining_cash": round(new_cash, 2)
|
|
}
|
|
|
|
|
|
# ── Pending Orders (Limit / Stop) ────────────────────────
|
|
|
|
def place_limit_order(ticker, side, shares, limit_price, asset_type="stock", expires_hours=24):
|
|
"""Place a limit order that fills when market price hits the target."""
|
|
if shares <= 0 or limit_price <= 0:
|
|
return {"error": "Invalid order parameters"}
|
|
|
|
expires = datetime.now()
|
|
from datetime import timedelta
|
|
expires = (expires + timedelta(hours=expires_hours)).isoformat()
|
|
|
|
conn = get_db()
|
|
conn.execute(
|
|
"""INSERT INTO pending_orders (ticker, asset_type, order_type, side, shares, target_price, expires_at)
|
|
VALUES (?, ?, 'LIMIT', ?, ?, ?, ?)""",
|
|
(ticker, asset_type, side, shares, limit_price, expires)
|
|
)
|
|
conn.commit()
|
|
order_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.close()
|
|
|
|
return {"success": True, "order_id": order_id, "order_type": "LIMIT",
|
|
"side": side, "ticker": ticker, "shares": shares,
|
|
"target_price": limit_price, "expires_at": expires}
|
|
|
|
|
|
def place_stop_order(ticker, side, shares, stop_price, asset_type="stock", expires_hours=24):
|
|
"""Place a stop order (stop-loss or take-profit)."""
|
|
if shares <= 0 or stop_price <= 0:
|
|
return {"error": "Invalid order parameters"}
|
|
|
|
expires = datetime.now()
|
|
from datetime import timedelta
|
|
expires = (expires + timedelta(hours=expires_hours)).isoformat()
|
|
|
|
conn = get_db()
|
|
conn.execute(
|
|
"""INSERT INTO pending_orders (ticker, asset_type, order_type, side, shares, target_price, expires_at)
|
|
VALUES (?, ?, 'STOP', ?, ?, ?, ?)""",
|
|
(ticker, asset_type, side, shares, stop_price, expires)
|
|
)
|
|
conn.commit()
|
|
order_id = conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
|
conn.close()
|
|
|
|
return {"success": True, "order_id": order_id, "order_type": "STOP",
|
|
"side": side, "ticker": ticker, "shares": shares,
|
|
"target_price": stop_price, "expires_at": expires}
|
|
|
|
|
|
def get_pending_orders():
|
|
"""Get all pending orders."""
|
|
conn = get_db()
|
|
# Expire old orders
|
|
conn.execute("UPDATE pending_orders SET status = 'EXPIRED' WHERE status = 'PENDING' AND expires_at < datetime('now')")
|
|
conn.commit()
|
|
rows = conn.execute("SELECT * FROM pending_orders WHERE status = 'PENDING' ORDER BY created_at DESC").fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def cancel_order(order_id):
|
|
"""Cancel a pending order."""
|
|
conn = get_db()
|
|
conn.execute("UPDATE pending_orders SET status = 'CANCELLED' WHERE id = ? AND status = 'PENDING'", (order_id,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"success": True, "order_id": order_id, "status": "CANCELLED"}
|
|
|
|
|
|
def check_and_fill_orders(current_prices):
|
|
"""Check pending orders against current prices and fill if conditions met.
|
|
Called during live polling.
|
|
current_prices: dict {ticker: price}
|
|
"""
|
|
conn = get_db()
|
|
pending = conn.execute("SELECT * FROM pending_orders WHERE status = 'PENDING'").fetchall()
|
|
filled = []
|
|
|
|
for order in pending:
|
|
order = dict(order)
|
|
ticker = order["ticker"]
|
|
if ticker not in current_prices:
|
|
continue
|
|
|
|
market_price = current_prices[ticker]
|
|
should_fill = False
|
|
|
|
if order["order_type"] == "LIMIT":
|
|
if order["side"] == "BUY" and market_price <= order["target_price"]:
|
|
should_fill = True
|
|
elif order["side"] == "SELL" and market_price >= order["target_price"]:
|
|
should_fill = True
|
|
elif order["order_type"] == "STOP":
|
|
if order["side"] == "SELL" and market_price <= order["target_price"]:
|
|
should_fill = True # stop-loss triggered
|
|
elif order["side"] == "BUY" and market_price >= order["target_price"]:
|
|
should_fill = True # stop-buy triggered
|
|
|
|
if should_fill:
|
|
conn.execute("UPDATE pending_orders SET status = 'FILLED', filled_at = datetime('now') WHERE id = ?",
|
|
(order["id"],))
|
|
|
|
if order["side"] == "BUY":
|
|
result = buy(ticker, order["shares"], market_price, order["asset_type"])
|
|
else:
|
|
result = sell(ticker, order["shares"], market_price)
|
|
|
|
filled.append({"order_id": order["id"], "ticker": ticker, "result": result})
|
|
|
|
conn.commit()
|
|
conn.close()
|
|
return filled
|
|
|
|
|
|
# ── Equity Curve ──────────────────────────────────────
|
|
|
|
def record_equity_point(total_value, cash, positions_value):
|
|
"""Record a point on the equity curve."""
|
|
conn = get_db()
|
|
# Get last point for daily return calc
|
|
last = conn.execute("SELECT total_value FROM equity_curve ORDER BY id DESC LIMIT 1").fetchone()
|
|
daily_return = 0
|
|
if last and last["total_value"] > 0:
|
|
daily_return = (total_value - last["total_value"]) / last["total_value"]
|
|
|
|
conn.execute(
|
|
"INSERT INTO equity_curve (timestamp, total_value, cash, positions_value, daily_return) VALUES (datetime('now'), ?, ?, ?, ?)",
|
|
(total_value, cash, positions_value, daily_return)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def get_equity_curve(limit=500):
|
|
"""Get equity curve data for charting."""
|
|
conn = get_db()
|
|
rows = conn.execute(
|
|
"SELECT timestamp, total_value, cash, positions_value, daily_return FROM equity_curve ORDER BY id DESC LIMIT ?",
|
|
(limit,)
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in reversed(rows)]
|
|
|
|
|
|
# ── Portfolio Summary ─────────────────────────────────
|
|
|
|
def get_portfolio_summary(current_prices=None):
|
|
if current_prices is None:
|
|
current_prices = {}
|
|
|
|
conn = get_db()
|
|
meta = _get_meta(conn)
|
|
cash = meta["cash"]
|
|
margin_used = meta["margin_used"]
|
|
positions = conn.execute("SELECT * FROM positions WHERE shares > 0").fetchall()
|
|
conn.close()
|
|
|
|
pos_list = []
|
|
total_long_value = 0
|
|
total_short_value = 0
|
|
total_cost_basis = 0
|
|
|
|
for p in positions:
|
|
ticker = p["ticker"]
|
|
shares = p["shares"]
|
|
avg_cost = p["avg_cost"]
|
|
side = p["side"]
|
|
current_price = current_prices.get(ticker, avg_cost)
|
|
|
|
if side == "LONG":
|
|
market_value = shares * current_price
|
|
cost_basis = shares * avg_cost
|
|
pnl = market_value - cost_basis
|
|
total_long_value += market_value
|
|
else: # SHORT
|
|
market_value = shares * avg_cost # short value is entry value
|
|
cost_basis = shares * avg_cost
|
|
pnl = (avg_cost - current_price) * shares # profit when price drops
|
|
total_short_value += shares * current_price
|
|
|
|
pnl_pct = (pnl / cost_basis * 100) if cost_basis > 0 else 0
|
|
total_cost_basis += cost_basis
|
|
|
|
pos_list.append({
|
|
"ticker": ticker, "asset_type": p["asset_type"],
|
|
"shares": shares, "avg_cost": round(avg_cost, 2),
|
|
"current_price": round(current_price, 2),
|
|
"market_value": round(market_value, 2),
|
|
"side": side,
|
|
"cost_basis": round(cost_basis, 2),
|
|
"pnl": round(pnl, 2), "pnl_pct": round(pnl_pct, 2),
|
|
"allocation_pct": 0
|
|
})
|
|
|
|
positions_value = total_long_value
|
|
# total_value = available cash + long positions + margin held + unrealized short P&L
|
|
# margin_used tracks entry value of shorts (deducted from cash at open)
|
|
# short_unrealized_pnl = sum of (entry_price - current_price) * shares for shorts
|
|
short_unrealized_pnl = 0
|
|
for p in pos_list:
|
|
if p["side"] == "SHORT":
|
|
short_unrealized_pnl += p["pnl"]
|
|
total_value = cash + positions_value + margin_used + short_unrealized_pnl
|
|
total_pnl = total_value - INITIAL_CASH
|
|
total_pnl_pct = (total_pnl / INITIAL_CASH * 100)
|
|
|
|
for p in pos_list:
|
|
p["allocation_pct"] = round((abs(p["market_value"]) / total_value * 100) if total_value > 0 else 0, 2)
|
|
|
|
return {
|
|
"cash": round(cash, 2),
|
|
"positions_value": round(positions_value, 2),
|
|
"total_value": round(total_value, 2),
|
|
"total_pnl": round(total_pnl, 2),
|
|
"total_pnl_pct": round(total_pnl_pct, 2),
|
|
"initial_capital": INITIAL_CASH,
|
|
"margin_used": round(margin_used, 2),
|
|
"buying_power": round(cash, 2),
|
|
"positions": pos_list,
|
|
"cash_allocation_pct": round((cash / total_value * 100) if total_value > 0 else 100, 2),
|
|
"slippage_bps": round(meta["slippage_bps"], 1),
|
|
"commission_per_share": round(meta["commission_per_share"], 4),
|
|
}
|
|
|
|
|
|
def get_transactions(limit=50):
|
|
conn = get_db()
|
|
rows = conn.execute(
|
|
"SELECT * FROM transactions ORDER BY timestamp DESC LIMIT ?", (limit,)
|
|
).fetchall()
|
|
conn.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_analytics(current_prices=None):
|
|
"""Enhanced analytics with Sortino, Calmar, profit factor."""
|
|
conn = get_db()
|
|
txns = conn.execute("SELECT * FROM transactions ORDER BY timestamp ASC").fetchall()
|
|
equity = conn.execute("SELECT * FROM equity_curve ORDER BY id ASC").fetchall()
|
|
conn.close()
|
|
|
|
if not txns:
|
|
return _empty_analytics()
|
|
|
|
# Daily returns from equity curve
|
|
daily_returns = [dict(e)["daily_return"] for e in equity if dict(e)["daily_return"] != 0]
|
|
|
|
if len(daily_returns) >= 2:
|
|
avg_ret = sum(daily_returns) / len(daily_returns)
|
|
std_ret = math.sqrt(sum((r - avg_ret) ** 2 for r in daily_returns) / len(daily_returns))
|
|
neg_returns = [r for r in daily_returns if r < 0]
|
|
downside_dev = math.sqrt(sum(r ** 2 for r in neg_returns) / len(neg_returns)) if neg_returns else 0
|
|
|
|
sharpe = (avg_ret / std_ret * math.sqrt(252)) if std_ret > 0 else 0
|
|
sortino = (avg_ret / downside_dev * math.sqrt(252)) if downside_dev > 0 else 0
|
|
|
|
# Max drawdown
|
|
values = [dict(e)["total_value"] for e in equity]
|
|
peak = values[0] if values else INITIAL_CASH
|
|
max_dd = 0
|
|
for v in values:
|
|
if v > peak: peak = v
|
|
dd = (peak - v) / peak
|
|
if dd > max_dd: max_dd = dd
|
|
|
|
# Calmar ratio (annualized return / max drawdown)
|
|
total_return = (values[-1] / values[0] - 1) if values and values[0] > 0 else 0
|
|
calmar = (total_return / max_dd) if max_dd > 0 else 0
|
|
else:
|
|
sharpe = sortino = calmar = 0
|
|
max_dd = 0
|
|
|
|
# Trade analysis
|
|
pnls = []
|
|
for t in txns:
|
|
t = dict(t)
|
|
if t.get("pnl") is not None:
|
|
pnls.append(t["pnl"])
|
|
|
|
wins = [p for p in pnls if p > 0]
|
|
losses = [p for p in pnls if p <= 0]
|
|
total_trades = len(pnls)
|
|
win_rate = (len(wins) / total_trades * 100) if total_trades > 0 else 0
|
|
|
|
gross_profit = sum(wins) if wins else 0
|
|
gross_loss = abs(sum(losses)) if losses else 0
|
|
profit_factor = (gross_profit / gross_loss) if gross_loss > 0 else float('inf') if gross_profit > 0 else 0
|
|
|
|
# Total commissions and slippage
|
|
total_commission = sum(dict(t).get("commission", 0) for t in txns)
|
|
total_slippage = sum(abs(dict(t).get("slippage", 0)) for t in txns)
|
|
|
|
summary = get_portfolio_summary(current_prices)
|
|
|
|
return {
|
|
"sharpe_ratio": round(sharpe, 2),
|
|
"sortino_ratio": round(sortino, 2),
|
|
"calmar_ratio": round(calmar, 2),
|
|
"max_drawdown_pct": round(max_dd * 100, 2),
|
|
"profit_factor": round(profit_factor, 2) if profit_factor != float('inf') else "∞",
|
|
"win_rate": round(win_rate, 1),
|
|
"total_trades": total_trades,
|
|
"winning_trades": len(wins),
|
|
"losing_trades": len(losses),
|
|
"avg_win": round(sum(wins) / len(wins), 2) if wins else 0,
|
|
"avg_loss": round(sum(losses) / len(losses), 2) if losses else 0,
|
|
"best_trade": round(max(pnls), 2) if pnls else 0,
|
|
"worst_trade": round(min(pnls), 2) if pnls else 0,
|
|
"total_return_pct": round(summary["total_pnl_pct"], 2),
|
|
"total_commission": round(total_commission, 2),
|
|
"total_slippage": round(total_slippage, 2),
|
|
"gross_profit": round(gross_profit, 2),
|
|
"gross_loss": round(gross_loss, 2),
|
|
}
|
|
|
|
|
|
def _empty_analytics():
|
|
return {
|
|
"sharpe_ratio": 0, "sortino_ratio": 0, "calmar_ratio": 0,
|
|
"max_drawdown_pct": 0, "profit_factor": 0,
|
|
"win_rate": 0, "total_trades": 0,
|
|
"winning_trades": 0, "losing_trades": 0,
|
|
"avg_win": 0, "avg_loss": 0,
|
|
"best_trade": 0, "worst_trade": 0,
|
|
"total_return_pct": 0, "total_commission": 0, "total_slippage": 0,
|
|
"gross_profit": 0, "gross_loss": 0,
|
|
}
|
|
|
|
|
|
def save_daily_snapshot(total_value, cash, positions_value):
|
|
conn = get_db()
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
conn.execute(
|
|
"INSERT OR REPLACE INTO portfolio_snapshots (date, total_value, cash, positions_value) VALUES (?, ?, ?, ?)",
|
|
(today, total_value, cash, positions_value)
|
|
)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
|
|
def reset_portfolio():
|
|
"""Reset portfolio to initial state — clears everything."""
|
|
conn = get_db()
|
|
conn.execute("DELETE FROM portfolio_meta")
|
|
conn.execute("INSERT INTO portfolio_meta (id, cash) VALUES (1, ?)", (INITIAL_CASH,))
|
|
conn.execute("DELETE FROM positions")
|
|
conn.execute("DELETE FROM transactions")
|
|
conn.execute("DELETE FROM pending_orders")
|
|
conn.execute("DELETE FROM equity_curve")
|
|
conn.execute("DELETE FROM portfolio_snapshots")
|
|
conn.commit()
|
|
conn.close()
|
|
return {"success": True, "message": "Portfolio reset to $100,000"}
|
|
|
|
|
|
def update_settings(slippage_bps=None, commission_per_share=None):
|
|
"""Update portfolio simulation settings."""
|
|
conn = get_db()
|
|
if slippage_bps is not None:
|
|
conn.execute("UPDATE portfolio_meta SET slippage_bps = ? WHERE id = 1", (slippage_bps,))
|
|
if commission_per_share is not None:
|
|
conn.execute("UPDATE portfolio_meta SET commission_per_share = ? WHERE id = 1", (commission_per_share,))
|
|
conn.commit()
|
|
conn.close()
|
|
return {"success": True}
|