Fix unmanaged reconciliation review blockers
Co-authored-by: genichin <4282227+genichin@users.noreply.github.com>
This commit is contained in:
co-authored by
genichin
parent
f9284d1de8
commit
591db7adb7
@@ -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`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user