Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@ updates:
schedule:
interval: "weekly"
open-pull-requests-limit: 5
ignore:
- dependency-name: "*"
update-types:
- "version-update:semver-major"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 5
ignore:
- dependency-name: "*"
update-types:
- "version-update:semver-major"
71 changes: 71 additions & 0 deletions .github/workflows/dependabot_auto_merge.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
name: Auto Merge Dependabot PR

"on":
workflow_run:
workflows: ["CI"]
types: [completed]

jobs:
auto-merge:
if: github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'dependabot/')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write

steps:
- name: Resolve Dependabot PR
id: pr
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BRANCH_NAME="${{ github.event.workflow_run.head_branch }}"
PR_NUMBER=$(gh pr list --state open --head "${BRANCH_NAME}" --json number --jq '.[0].number // empty')
if [ -z "${PR_NUMBER}" ]; then
echo "No open Dependabot PR found for ${BRANCH_NAME}." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"

- name: Evaluate merge eligibility
id: merge_guard
if: steps.pr.outputs.pr_number != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr view "${{ steps.pr.outputs.pr_number }}" --json number,isDraft,author,url,labels > pr.json
python3 - <<'PY'
import json
import os
from pathlib import Path

pr = json.loads(Path("pr.json").read_text(encoding="utf-8"))
author = (pr.get("author") or {}).get("login")
labels = {item.get("name", "") for item in pr.get("labels", [])}
should_merge = author == "dependabot[bot]" and not pr.get("isDraft")
reason = "ready" if should_merge else "not_dependabot_or_draft"
Comment on lines +45 to +46
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify PR head SHA before merging

In .github/workflows/dependabot_auto_merge.yml, merge eligibility is based only on author/draft status and branch name, so a successful workflow_run for an older commit on the same Dependabot branch can still merge the PR after newer commits are pushed. This can merge an unverified or failing head commit if CI for the latest SHA has not passed yet. Add a guard that compares github.event.workflow_run.head_sha with the PR’s current head SHA (for example from gh pr view --json headRefOid) before allowing gh pr merge.

Useful? React with 👍 / 👎.


summary_lines = [
"## Auto-Merge Gate",
f"- PR: {pr['url']}",
f"- Author: `{author or '<unknown>'}`",
f"- Draft: `{ 'yes' if pr.get('isDraft') else 'no' }`",
f"- Dependabot label: `{ 'yes' if 'dependencies' in labels else 'no' }`",
f"- Final merge decision: `{ 'merge' if should_merge else 'skip' }`",
f"- Reason: `{reason}`",
]
Path("pr-summary.md").write_text("\n".join(summary_lines).strip() + "\n", encoding="utf-8")
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
print(f"should_merge={'true' if should_merge else 'false'}", file=output)
print(f"reason={reason}", file=output)
PY

- name: Append merge summary
if: steps.pr.outputs.pr_number != ''
run: cat pr-summary.md >> "$GITHUB_STEP_SUMMARY"

- name: Merge Dependabot PR
if: steps.merge_guard.outputs.should_merge == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr merge "${{ steps.pr.outputs.pr_number }}" --rebase --delete-branch
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "quant-platform-kit"
version = "0.7.21"
version = "0.7.22"
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

setup(
name="quant-platform-kit",
version="0.7.21",
version="0.7.22",
description="Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies.",
package_dir={"": "src"},
packages=find_packages(where="src"),
Expand Down
2 changes: 2 additions & 0 deletions src/quant_platform_kit/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class Position:
market_value: float
average_cost: float | None = None
currency: str = "USD"
account_id: str | None = None


@dataclass(frozen=True)
Expand All @@ -62,6 +63,7 @@ class OrderIntent:
order_type: str = "market"
limit_price: float | None = None
time_in_force: str | None = None
account_id: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)


Expand Down
17 changes: 17 additions & 0 deletions src/quant_platform_kit/ibkr/execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
from quant_platform_kit.common.models import ExecutionReport, OrderIntent


def _normalize_account_id(value: str | None) -> str | None:
text = str(value or "").strip()
return text or None


def _build_stock_contract(
symbol: str,
*,
Expand All @@ -23,6 +28,7 @@ def submit_order_intent(
ib: Any,
order_intent: OrderIntent,
*,
account_id: str | None = None,
wait_seconds: float = 1.0,
stock_factory: Callable[..., Any] | None = None,
market_order_factory: Callable[..., Any] | None = None,
Expand Down Expand Up @@ -55,6 +61,16 @@ def submit_order_intent(
else:
raise ValueError(f"Unsupported IBKR order type: {order_intent.order_type!r}")

intent_account_id = _normalize_account_id(order_intent.account_id)
explicit_account_id = _normalize_account_id(account_id)
if intent_account_id and explicit_account_id and intent_account_id != explicit_account_id:
raise ValueError(
"OrderIntent.account_id conflicts with submit_order_intent(account_id=...)."
)
resolved_account_id = intent_account_id or explicit_account_id
if resolved_account_id:
order.account = resolved_account_id

trade = ib.placeOrder(contract, order)
if wait_seconds:
import time as time_module
Expand All @@ -73,5 +89,6 @@ def submit_order_intent(
raw_payload={
"order_type": order_type,
"time_in_force": getattr(order, "tif", None),
"account_id": resolved_account_id,
},
)
44 changes: 40 additions & 4 deletions src/quant_platform_kit/ibkr/portfolio.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,39 @@
from __future__ import annotations

from datetime import datetime
from typing import Any
from typing import Any, Iterable

from quant_platform_kit.common.models import PortfolioSnapshot, Position


def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> PortfolioSnapshot:
def _normalize_account_ids(account_ids: Iterable[str] | str | None) -> tuple[str, ...]:
if account_ids is None:
return ()
if isinstance(account_ids, str):
candidates = [account_ids]
else:
candidates = list(account_ids)
normalized = []
for candidate in candidates:
text = str(candidate or "").strip()
if text:
normalized.append(text)
return tuple(dict.fromkeys(normalized))


def _matches_account(account_id: str | None, selected_account_ids: tuple[str, ...]) -> bool:
if not selected_account_ids:
return True
return str(account_id or "").strip() in selected_account_ids


def fetch_portfolio_snapshot(
ib: Any,
*,
account_ids: Iterable[str] | str | None = None,
wait_seconds: float = 1.0,
) -> PortfolioSnapshot:
selected_account_ids = _normalize_account_ids(account_ids)
ib.reqPositions()
if wait_seconds:
import time as time_module
Expand All @@ -15,6 +42,9 @@ def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> Portfolio

positions = []
for raw_position in ib.positions():
account_id = str(getattr(raw_position, "account", "") or "").strip() or None
if not _matches_account(account_id, selected_account_ids):
continue
if raw_position.position == 0:
continue
quantity = float(raw_position.position)
Expand All @@ -25,22 +55,28 @@ def fetch_portfolio_snapshot(ib: Any, *, wait_seconds: float = 1.0) -> Portfolio
quantity=quantity,
market_value=quantity * average_cost,
average_cost=average_cost,
account_id=account_id,
)
)

total_equity = 0.0
buying_power = None
for account_value in ib.accountValues():
account_id = str(getattr(account_value, "account", "") or "").strip() or None
if not _matches_account(account_id, selected_account_ids):
continue
if account_value.currency != "USD":
continue
if account_value.tag == "NetLiquidation":
total_equity = float(account_value.value)
total_equity += float(account_value.value)
elif account_value.tag == "AvailableFunds":
buying_power = float(account_value.value)
value = float(account_value.value)
buying_power = value if buying_power is None else buying_power + value

return PortfolioSnapshot(
as_of=datetime.utcnow(),
total_equity=total_equity,
buying_power=buying_power,
positions=tuple(positions),
metadata={"account_ids": selected_account_ids},
)
26 changes: 26 additions & 0 deletions tests/test_ibkr_execution.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,32 @@ def test_submit_limit_order_sets_time_in_force(self) -> None:
self.assertEqual(report.raw_payload["time_in_force"], "DAY")
self.assertEqual(ib.orders[0][1].tif, "DAY")

def test_submit_order_intent_sets_account_when_provided(self) -> None:
ib = FakeIB()
report = submit_order_intent(
ib,
OrderIntent(symbol="SPY", side="buy", quantity=5, account_id="U18308207"),
wait_seconds=0,
stock_factory=FakeContract,
market_order_factory=FakeMarketOrder,
)

self.assertEqual(ib.orders[0][1].account, "U18308207")
self.assertEqual(report.raw_payload["account_id"], "U18308207")

def test_submit_order_intent_rejects_conflicting_account_id(self) -> None:
ib = FakeIB()

with self.assertRaises(ValueError):
submit_order_intent(
ib,
OrderIntent(symbol="SPY", side="buy", quantity=5, account_id="U18308207"),
account_id="U15998061",
wait_seconds=0,
stock_factory=FakeContract,
market_order_factory=FakeMarketOrder,
)


if __name__ == "__main__":
unittest.main()
76 changes: 43 additions & 33 deletions tests/test_ibkr_portfolio.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,66 @@
from __future__ import annotations

from dataclasses import dataclass
from types import SimpleNamespace
import unittest

from quant_platform_kit.ibkr.portfolio import fetch_portfolio_snapshot


@dataclass
class FakeContract:
symbol: str


@dataclass
class FakePosition:
contract: FakeContract
position: int
avgCost: float


@dataclass
class FakeAccountValue:
tag: str
currency: str
value: str


class FakeIB:
def __init__(self):
self.req_positions_called = False

def reqPositions(self):
self.positions_requested = True
self.req_positions_called = True

def positions(self):
return [
FakePosition(contract=FakeContract("SPY"), position=10, avgCost=99.0),
FakePosition(contract=FakeContract("AGG"), position=0, avgCost=100.0),
SimpleNamespace(
account="U18308207",
contract=SimpleNamespace(symbol="TQQQ"),
position=3,
avgCost=100.0,
),
SimpleNamespace(
account="U15998061",
contract=SimpleNamespace(symbol="AAPL"),
position=5,
avgCost=200.0,
),
]

def accountValues(self):
return [
FakeAccountValue(tag="NetLiquidation", currency="USD", value="100000"),
FakeAccountValue(tag="AvailableFunds", currency="USD", value="25000"),
SimpleNamespace(account="U18308207", tag="NetLiquidation", currency="USD", value="1000"),
SimpleNamespace(account="U18308207", tag="AvailableFunds", currency="USD", value="250"),
SimpleNamespace(account="U15998061", tag="NetLiquidation", currency="USD", value="2000"),
SimpleNamespace(account="U15998061", tag="AvailableFunds", currency="USD", value="500"),
]


class IbkrPortfolioTests(unittest.TestCase):
def test_fetch_portfolio_snapshot_returns_equity_and_positions(self) -> None:
snapshot = fetch_portfolio_snapshot(FakeIB(), wait_seconds=0)

self.assertEqual(snapshot.total_equity, 100000.0)
self.assertEqual(snapshot.buying_power, 25000.0)
self.assertEqual(len(snapshot.positions), 1)
self.assertEqual(snapshot.positions[0].symbol, "SPY")
self.assertEqual(snapshot.positions[0].market_value, 990.0)
def test_fetch_portfolio_snapshot_filters_by_account_id(self) -> None:
ib = FakeIB()

snapshot = fetch_portfolio_snapshot(ib, account_ids=("U18308207",), wait_seconds=0)

self.assertTrue(ib.req_positions_called)
self.assertEqual(snapshot.total_equity, 1000.0)
self.assertEqual(snapshot.buying_power, 250.0)
self.assertEqual(tuple(position.symbol for position in snapshot.positions), ("TQQQ",))
self.assertEqual(snapshot.positions[0].account_id, "U18308207")
self.assertEqual(snapshot.metadata["account_ids"], ("U18308207",))

def test_fetch_portfolio_snapshot_sums_selected_accounts(self) -> None:
snapshot = fetch_portfolio_snapshot(
FakeIB(),
account_ids=("U18308207", "U15998061"),
wait_seconds=0,
)

self.assertEqual(snapshot.total_equity, 3000.0)
self.assertEqual(snapshot.buying_power, 750.0)
self.assertEqual(tuple(position.symbol for position in snapshot.positions), ("TQQQ", "AAPL"))


if __name__ == "__main__":
Expand Down