Fix unmanaged reconciliation review blockers

Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-09-13 02:21:37 +00:00
committed by GitHub
co-authored by genichin
parent f9284d1de8
commit 591db7adb7
26 changed files with 484 additions and 87 deletions
+4 -3
View File
@@ -382,9 +382,7 @@ class KISBroker:
transport_failure: bool,
) -> float:
"""Return bounded state/age-based delay for the next authoritative poll."""
if status.state != "unknown":
delay = _FILL_POLL_MIN_INTERVAL_SECONDS
elif age_seconds < 60:
if status.state != "unknown" or age_seconds < 60:
delay = _FILL_POLL_MIN_INTERVAL_SECONDS
elif age_seconds < 5 * 60:
delay = 15.0
@@ -1284,6 +1282,9 @@ class KISBroker:
seen_continuations.add(next_continuation)
continuation = next_continuation
def exposes_full_balance_snapshot(self) -> bool:
return True
def get_available_cash(self, *, price_check_code: str = "005930") -> int:
"""Return KIS cash-only buy capacity (`nrcvb_buy_amt`).
+4
View File
@@ -17,6 +17,7 @@ from threading import Lock
from trex_core.application.broker_port import (
BalanceItem,
BrokerBalanceError,
BrokerPort,
CancelOutcome,
FillStatus,
OrderAck,
@@ -174,6 +175,9 @@ class OwnershipAwareBroker:
def get_balance(self) -> list[BalanceItem]:
return self._store.project(self._delegate.get_balance())
def authoritative_balance_reader(self) -> BrokerPort:
return self._delegate
def exposes_full_balance_snapshot(self) -> bool:
"""False: projected balances omit operator-owned/unmanaged broker positions."""
return False
+3
View File
@@ -166,6 +166,9 @@ class PaperBroker:
return [BalanceItem(symbol=sym, hldg_qty=qty)
for sym, qty in balances.items() if qty != 0]
def exposes_full_balance_snapshot(self) -> bool:
return True
# --- REQ-038: programmable current price (safety-sell scenario double) ---
def set_current_price(self, symbol: str, price: Decimal) -> None:
"""Program the current price returned by get_current_price(symbol)."""
@@ -59,6 +59,8 @@ class PostgreSQLUnmanagedPositionStore:
"UPDATE unmanaged_position_state "
"SET quantity = %s, lifecycle_state = 'active', "
" last_seen_at = %s, quantity_changed_at = %s, resolved_at = NULL, "
" operator_action = NULL, evidence_ref = NULL, operator_note = '', "
" operator_actor = NULL, operator_updated_at = NULL, "
" updated_at = %s "
"WHERE instance_key = %s AND symbol = %s "
f"RETURNING {_STATE_COLUMNS}",
-5
View File
@@ -615,11 +615,6 @@ class InMemoryUnmanagedPositionStore:
existing = self._states.get(key)
previous_quantity = existing.get("quantity") if existing is not None else None
preserved = {} if existing is None else {
"operator_action": existing.get("operator_action"),
"evidence_ref": existing.get("evidence_ref"),
"operator_note": existing.get("operator_note"),
"operator_actor": existing.get("operator_actor"),
"operator_updated_at": existing.get("operator_updated_at"),
"created_at": existing.get("created_at", now),
}
if existing is None or existing.get("lifecycle_state") == "resolved":
+1 -1
View File
@@ -373,7 +373,7 @@ def _broker_balances_are_authoritative_for_unmanaged_resolution(broker: BrokerPo
return False
if isinstance(marker, bool):
return marker
return True
return False
def reconcile_core_holdings(
+3 -1
View File
@@ -44,6 +44,7 @@ class SnapshotScheduler:
read_port: PortfolioHubReadPort,
store: SnapshotStore,
broker: BrokerPort,
core_holdings_broker: BrokerPort | None = None,
schedule_kst: time = time(hour=7, minute=30),
clock: Callable[[], datetime] | None = None,
slot_repo=None,
@@ -60,6 +61,7 @@ class SnapshotScheduler:
self._read = read_port
self._store = store
self._broker = broker
self._core_holdings_broker = core_holdings_broker or broker
self._schedule_kst = schedule_kst
self._clock = clock or (lambda: datetime.now(tz=UTC))
self._daemon_id: str | None = None
@@ -337,7 +339,7 @@ class SnapshotScheduler:
# BAT-050 / REQ-103: pass warn_store for B-case durable warning.
reconcile_core_holdings(
slot_repo=self._slot_repo, slot_service=self._slot_service,
broker=self._broker, instance_key=self._instance_key,
broker=self._core_holdings_broker, instance_key=self._instance_key,
alarm_emitter=self._alarm_emitter, # dcy-033 / REQ-100
warn_store=self._warn_store, # BAT-050 / REQ-103
notification_store=self._notification_store, # req-0007 / ac-0024
+12 -4
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import os
from dataclasses import dataclass
from datetime import date, datetime, timedelta, timezone
from datetime import UTC, date, datetime, timedelta, timezone
from typing import Literal, Protocol
from trex_core.adapters.broker.paper import PaperBroker
@@ -33,6 +33,7 @@ class BrokerCredentialUnavailable(RuntimeError):
class BrokerProvider(Protocol):
def get_broker_for_read(self) -> tuple[BrokerPort, BrokerSource]: ...
def get_broker_for_reconciliation(self) -> tuple[BrokerPort, BrokerSource]: ...
def refresh_day_start_credential(self) -> BrokerSource: ...
@@ -80,7 +81,7 @@ class ConfigBrokerProvider:
def __init__(self, config: DaemonConfig, *, read_port=None, clock=None) -> None:
self._config = config
self._read = read_port
self._clock = clock or (lambda: datetime.now(tz=timezone.utc))
self._clock = clock or (lambda: datetime.now(tz=UTC))
self._broker: BrokerPort | None = None
self._source: BrokerSource | None = None
self._credential_date: date | None = None
@@ -101,7 +102,7 @@ class ConfigBrokerProvider:
raise BrokerCredentialUnavailable("portfolio_hub_unavailable") from None
except BrokerCredentialUnavailable:
raise
except Exception:
except Exception: # noqa: BLE001 - sanitize arbitrary read adapter failures
raise BrokerCredentialUnavailable("portfolio_hub_unavailable") from None
if ctx is None or getattr(ctx, "broker", None) is None:
raise BrokerCredentialUnavailable("broker_absent")
@@ -121,7 +122,7 @@ class ConfigBrokerProvider:
self._broker = broker
else:
self._broker.reinitialize(credential) # type: ignore[attr-defined]
except Exception:
except Exception: # noqa: BLE001 - sanitize broker construction/reload failures
raise BrokerCredentialUnavailable("broker_initialization_failed") from None
self._credential_date = self._clock().astimezone(_KST).date()
# Version is designated non-secret by the portfolio contract; no account/key material.
@@ -147,6 +148,13 @@ class ConfigBrokerProvider:
self._source = source
return self._broker, source # type: ignore[return-value]
def get_broker_for_reconciliation(self) -> tuple[BrokerPort, BrokerSource]:
broker, source = self.get_broker_for_read()
authoritative_reader = getattr(broker, "authoritative_balance_reader", None)
if callable(authoritative_reader):
return authoritative_reader(), source
return broker, source
def refresh_day_start_credential(self) -> BrokerSource:
# Paper/env/break-glass paths deliberately do no portfolio-hub call.
if self._config.broker != "kis" or not self._config.credential_fetch_enabled or _break_glass_enabled():
+58 -2
View File
@@ -12,6 +12,7 @@ Subcommands:
sync — sync slot qty to broker ground truth (direct-DB; REQ-105)
verify — re-check C-case reconcile-warning (direct-DB; REQ-106)
unmanaged list — query durable unmanaged-position lifecycle state
unmanaged audit — inspect lifecycle/operator audit trail for one symbol
unmanaged record — record explicit operator disposition + evidence reference
ledger backfill — evidence-backed historical ledger recovery
ledger skips list — query durable skipped-ledger records
@@ -38,6 +39,7 @@ LedgerWriter path, and resolves matching durable skipped-ledger records.
from __future__ import annotations
import argparse
import getpass
import json as _json
import logging
import re
@@ -78,6 +80,14 @@ def _resolve_instance(args) -> str:
return topology.DEFAULT_INSTANCE_KEY
def _operator_actor() -> str:
try:
username = getpass.getuser().strip()
except Exception: # noqa: BLE001
username = ""
return f"cli:{username or 'unknown'}"
# --------------------------------------------------------------------------- #
# default transport-backed RPC (overridable in tests)
# --------------------------------------------------------------------------- #
@@ -659,6 +669,13 @@ def _build_parser() -> argparse.ArgumentParser:
p_unmanaged_list.add_argument("--symbol", default=None)
_add_common(p_unmanaged_list)
p_unmanaged_audit = p_unmanaged_sub.add_parser(
"audit",
help="Show lifecycle/operator audit trail for one unmanaged position",
)
p_unmanaged_audit.add_argument("symbol")
_add_common(p_unmanaged_audit)
p_unmanaged_record = p_unmanaged_sub.add_parser(
"record",
help="Record an explicit operator disposition for an unmanaged position",
@@ -670,7 +687,6 @@ def _build_parser() -> argparse.ArgumentParser:
)
p_unmanaged_record.add_argument("--evidence-ref", required=True)
p_unmanaged_record.add_argument("--note", default="")
p_unmanaged_record.add_argument("--actor", default="operator")
_add_common(p_unmanaged_record)
p_ledger = sub.add_parser(
@@ -1040,6 +1056,44 @@ def _cmd_unmanaged_list(args, cfg) -> int:
return 0
def _cmd_unmanaged_audit(args, cfg) -> int:
from trex_core.adapters.portfolio.unmanaged_position_store import (
PostgreSQLUnmanagedPositionStore,
)
instance = _resolve_instance(args)
store = PostgreSQLUnmanagedPositionStore(cfg.dsn)
state = store.get_state(instance_key=instance, symbol=args.symbol)
if state is None:
_fail(
f"unmanaged position not found: instance={instance} symbol={args.symbol}",
args.json,
)
return 1
audit = store.list_audit(instance_key=instance, symbol=args.symbol)
payload = {
"instance_key": instance,
"symbol": args.symbol,
"state": state,
"audit": audit,
}
if args.json:
_emit(payload, as_json=True)
else:
print(
f"instance={instance} symbol={args.symbol} lifecycle={state['lifecycle_state']} "
f"entries={len(audit)}"
)
for row in audit:
print(
f" {row['event_type']}\tat={row['recorded_at']}\t"
f"quantity={row.get('quantity')}\taction={row.get('action') or '-'}\t"
f"actor={row.get('actor') or '-'}\tevidence_ref={row.get('evidence_ref') or '-'}\t"
f"note={row.get('note') or '-'}"
)
return 0
def _cmd_unmanaged_record(args, cfg) -> int:
from trex_core.adapters.portfolio.unmanaged_position_store import (
PostgreSQLUnmanagedPositionStore,
@@ -1051,7 +1105,7 @@ def _cmd_unmanaged_record(args, cfg) -> int:
symbol=args.symbol,
action=args.action,
evidence_ref=args.evidence_ref,
actor=args.actor,
actor=_operator_actor(),
note=args.note,
)
if state is None:
@@ -1536,6 +1590,8 @@ def main(argv: list[str] | None = None, *, rpc=None, confirm=None) -> int:
return _cmd_verify(args, cfg)
if args.cmd == "unmanaged" and getattr(args, "unmanaged_cmd", None) == "list":
return _cmd_unmanaged_list(args, cfg)
if args.cmd == "unmanaged" and getattr(args, "unmanaged_cmd", None) == "audit":
return _cmd_unmanaged_audit(args, cfg)
if args.cmd == "unmanaged" and getattr(args, "unmanaged_cmd", None) == "record":
return _cmd_unmanaged_record(args, cfg)
+11 -7
View File
@@ -213,6 +213,9 @@ class Daemon:
from trex_core.runtime.broker_provider import build_broker_provider
self.broker_provider = build_broker_provider(config, read_port=self.portfolio_read)
self.broker, self.broker_source = self.broker_provider.get_broker_for_read()
self.reconciliation_broker, self.reconciliation_broker_source = (
self.broker_provider.get_broker_for_reconciliation()
)
self.account_snapshot_service: AccountSnapshotService | None = None
if config.account_snapshot.enabled:
if config.broker != "kis":
@@ -271,6 +274,7 @@ class Daemon:
read_port=self.portfolio_read,
store=self.snapshot_store,
broker=self.broker,
core_holdings_broker=self.reconciliation_broker,
schedule_kst=_parse_kst_time(config.snapshot_schedule.kst_time),
# dcy-020 / REQ-058·062 (BAT-030): universe slot reconcile (creates/
# retires the permanent asset slots) + core-holding divergence
@@ -425,7 +429,7 @@ class Daemon:
while not self.stop.wait(self.config.ledger_interval_sec):
try:
self.ledger_writer.run_once()
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001,S112
continue
def _intent_rpc_loop(self) -> None:
@@ -433,7 +437,7 @@ class Daemon:
self._transports.append(transport)
try:
transport.declare_topology(self.config.instance_key)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001,S110
pass
handler = make_intent_rpc_handler(
self.intent_service,
@@ -448,7 +452,7 @@ class Daemon:
self._transports.append(transport)
try:
transport.declare_topology(self.config.instance_key)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001,S110
pass
handler = make_slots_rpc_handler(
self.slot_repo,
@@ -472,7 +476,7 @@ class Daemon:
self._transports.append(transport)
try:
transport.declare_topology(self.config.instance_key)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001,S110
pass
handler = make_strategy_state_set_handler(
self.slot_repo, self.strategy_state_store,
@@ -486,7 +490,7 @@ class Daemon:
self._transports.append(transport)
try:
transport.declare_topology(self.config.instance_key)
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001,S110
pass
handler = make_control_rpc_handler(
self.pause_store,
@@ -501,7 +505,7 @@ class Daemon:
while not self.stop.wait(self.safety_sell.base_tick_sec):
try:
self.safety_sell.run_once()
except Exception: # noqa: BLE001 - loop must survive transient errors
except Exception: # noqa: BLE001,S112 - loop must survive transient errors
continue
def _snapshot_scheduler_loop(self) -> None:
@@ -566,7 +570,7 @@ class Daemon:
for tr in self._transports:
try:
tr.close()
except Exception: # noqa: BLE001
except Exception: # noqa: BLE001,S110
pass
def run(self) -> None: # pragma: no cover - process entrypoint