I think that's it for now.

This commit is contained in:
2026-04-01 22:38:04 -05:00
parent 1835fb89d8
commit 253858d46a
5 changed files with 467 additions and 103 deletions

View File

@@ -32,14 +32,22 @@ def _normalize_buy_time(open_dt: str | None) -> str | None:
return open_dt
def _compute_cagr_percent(cost_basis, gain_dollars, buy_time: str | None):
def _compute_cagr_percent(cost_basis, gain_dollars, market_value, buy_time: str | None):
if cost_basis in (None, 0) or gain_dollars is None or not buy_time:
return None
try:
start = datetime.fromisoformat(buy_time).replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
years = max((now - start).total_seconds() / (365.25 * 24 * 3600), 1e-9)
gain_percent = (float(gain_dollars) / abs(float(cost_basis))) * 100.0
if years < 1.0:
return gain_percent
start_value = abs(float(cost_basis))
if float(cost_basis) < 0:
end_liability = abs(float(market_value)) if market_value is not None else abs(float(cost_basis) + float(gain_dollars))
if start_value <= 0 or end_liability <= 0:
return None
return (((start_value / end_liability) ** (1.0 / years)) - 1.0) * 100.0
end_value = start_value + float(gain_dollars)
if start_value <= 0 or end_value <= 0:
return None
@@ -73,6 +81,20 @@ def _instrument_key(attrs: dict) -> str:
return (attrs.get("symbol") or "").strip()
def _signed_quantity(raw_position, side: str, cost_basis, market_value):
quantity = abs(raw_position or 0.0)
if quantity == 0:
return 0.0
normalized_side = (side or "").strip().lower()
if normalized_side == "short" or (raw_position or 0.0) < 0:
return -quantity
if cost_basis is not None and float(cost_basis) < 0:
return -quantity
if market_value is not None and float(market_value) < 0:
return -quantity
return quantity
def parse_flex_lots(xml_path: str | Path) -> list[dict]:
root = ET.parse(xml_path).getroot()
lots: list[dict] = []
@@ -83,27 +105,43 @@ def parse_flex_lots(xml_path: str | Path) -> list[dict]:
security_type = (attrs.get("assetCategory") or "").upper() or "STK"
multiplier = _as_float(attrs.get("multiplier"), 1.0) or 1.0
fx_rate_to_base = _as_float(attrs.get("fxRateToBase"), 1.0) or 1.0
raw_position = _as_float(attrs.get("position"), 0.0) or 0.0
side = (attrs.get("side") or "").strip().lower()
qty = -abs(raw_position) if side == "short" or raw_position < 0 else abs(raw_position)
mark_price = _as_float(attrs.get("markPrice"))
market_price = mark_price
market_value = _as_float(attrs.get("positionValue"))
cost_basis = _as_float(attrs.get("costBasisMoney"))
avg_price = _as_float(attrs.get("costBasisPrice"))
if avg_price is None:
open_price = _as_float(attrs.get("openPrice"))
avg_price = open_price
if avg_price is None and qty:
divisor = qty * multiplier if security_type == "OPT" else qty
avg_price = abs(cost_basis / divisor) if cost_basis is not None and divisor else None
buy_time = _normalize_buy_time(attrs.get("openDateTime"))
cost_basis_base = _as_float(attrs.get("costBasisMoney"))
capital_gain_base = _as_float(attrs.get("unrealizedCapitalGainsPnl"))
gain_dollars = _as_float(attrs.get("fifoPnlUnrealized"))
qty = _signed_quantity(raw_position, side, cost_basis_base, market_value)
# Flex OpenPosition rows mix currencies for non-base holdings:
# mark/positionValue are in instrument currency, while costBasis/openPrice
# are in account base. Reconstruct local-cost values so they line up with TWS.
cost_basis = cost_basis_base
if market_value is not None and capital_gain_base is not None and fx_rate_to_base:
cost_basis = market_value - (capital_gain_base / fx_rate_to_base)
avg_price = None
if qty:
divisor = abs(qty) * multiplier if security_type == "OPT" else abs(qty)
avg_price = abs(cost_basis / divisor) if cost_basis is not None and divisor else None
if avg_price is None:
avg_price = _as_float(attrs.get("costBasisPrice"))
if avg_price is None:
avg_price = _as_float(attrs.get("openPrice"))
buy_time = _normalize_buy_time(attrs.get("openDateTime"))
# Prefer local-currency capital gain to keep gain/cost/value in one currency.
if capital_gain_base is not None and fx_rate_to_base:
gain_dollars = capital_gain_base / fx_rate_to_base
gain_percent = None
if cost_basis not in (None, 0) and gain_dollars is not None:
gain_percent = (gain_dollars / abs(cost_basis)) * 100.0
cagr_percent = _compute_cagr_percent(cost_basis, gain_dollars, buy_time)
cagr_percent = _compute_cagr_percent(cost_basis, gain_dollars, market_value, buy_time)
days_since_buy = _compute_days_since_buy(buy_time)
ltcg = "X" if days_since_buy is not None and days_since_buy >= 365 else ""
@@ -112,6 +150,7 @@ def parse_flex_lots(xml_path: str | Path) -> list[dict]:
"con_id": _as_float(attrs.get("conid")),
"symbol": attrs.get("symbol"),
"display_symbol": _display_symbol(attrs),
"currency": attrs.get("currency") or None,
"security_type": security_type,
"underlying_symbol": attrs.get("underlyingSymbol") or attrs.get("symbol"),
"expiry": _normalize_expiry(attrs.get("expiry")),