diff --git a/src/grid_trading/domain/calculations.py b/src/grid_trading/domain/calculations.py index 7e8e60d..156204d 100644 --- a/src/grid_trading/domain/calculations.py +++ b/src/grid_trading/domain/calculations.py @@ -14,6 +14,7 @@ from grid_trading.domain.models import ( FeeRules, Instrument, PositionSummary, + QuoteSnapshot, Trade, TradeGroup, TradeSide, @@ -65,8 +66,10 @@ def calculate_positions( *, as_of: date, instrument_cashflows: Mapping[int, Decimal] | None = None, + quote_snapshots: Mapping[int, QuoteSnapshot] | None = None, ) -> list[PositionSummary]: cashflows = instrument_cashflows or {} + quotes = quote_snapshots or {} instrument_by_id = {instrument.id: instrument for instrument in instruments if instrument.id is not None} states: dict[int, dict[TradeGroup, _GroupState]] = defaultdict( lambda: {group: _GroupState() for group in TradeGroup} @@ -120,7 +123,11 @@ def calculate_positions( remaining_cost = money(sum((state.cost for state in group_states.values()), Decimal("0"))) realized_pnl = money(sum((state.realized_pnl for state in group_states.values()), Decimal("0"))) grid_profit = money(group_states[TradeGroup.GRID].realized_pnl) - current_price, price_source = _resolve_current_price(instrument, last_trade_price.get(instrument.id)) + current_price, price_source = _resolve_current_price( + instrument, + last_trade_price.get(instrument.id), + quotes.get(instrument.id), + ) market_value = money(current_price * Decimal(total_quantity)) if current_price is not None else None floating_pnl = money(market_value - remaining_cost) if market_value is not None else None available_quantity = max(0, total_quantity - today_buys[instrument.id]) @@ -203,7 +210,10 @@ def _validate_trade(trade: Trade) -> None: def _resolve_current_price( instrument: Instrument, fallback_trade_price: Decimal | None, + quote_snapshot: QuoteSnapshot | None, ) -> tuple[Decimal | None, str]: + if quote_snapshot is not None: + return quote_snapshot.price, quote_snapshot.source if instrument.manual_price is not None: return instrument.manual_price, "manual" if fallback_trade_price is not None: diff --git a/src/grid_trading/services/trading_service.py b/src/grid_trading/services/trading_service.py index 8f73254..7d07786 100644 --- a/src/grid_trading/services/trading_service.py +++ b/src/grid_trading/services/trading_service.py @@ -19,18 +19,22 @@ from grid_trading.domain.models import ( FeeRules, Instrument, PositionSummary, + QuoteSnapshot, StrategyOverride, StrategyTemplate, Trade, TradeSide, ) +from grid_trading.market.tencent import TencentQuoteProvider from grid_trading.storage.repositories import Repository class TradingService: - def __init__(self, db_path: str | Path): + def __init__(self, db_path: str | Path, *, quote_provider=None): self.repository = Repository(db_path) self.repository.initialize() + self.quote_provider = quote_provider or TencentQuoteProvider() + self._quote_cache: dict[str, QuoteSnapshot] = {} def close(self) -> None: self.repository.close() @@ -88,6 +92,12 @@ class TradingService: def list_instruments(self) -> list[Instrument]: return self.repository.list_instruments() + def refresh_quotes(self) -> dict[str, QuoteSnapshot]: + instruments = self.repository.list_instruments() + quotes = self.quote_provider.fetch_quotes(instruments) + self._quote_cache = quotes + return quotes + def get_default_strategy_template(self) -> StrategyTemplate: self.ensure_defaults() template = self.repository.get_default_strategy_template() @@ -168,6 +178,7 @@ class TradingService: trades, as_of=as_of_date, instrument_cashflows=self._instrument_cashflows(account.id), + quote_snapshots=self._quote_snapshots_by_instrument_id(instruments), ) def get_account_summary(self, *, as_of: date | None = None) -> AccountSummary: @@ -247,3 +258,16 @@ class TradingService: if entry.instrument_id is not None: totals[entry.instrument_id] += entry.amount return dict(totals) + + def _quote_snapshots_by_instrument_id( + self, + instruments: list[Instrument], + ) -> dict[int, QuoteSnapshot]: + snapshots: dict[int, QuoteSnapshot] = {} + for instrument in instruments: + if instrument.id is None: + continue + quote = self._quote_cache.get(instrument.code) + if quote is not None: + snapshots[instrument.id] = quote + return snapshots diff --git a/tests/test_calculations.py b/tests/test_calculations.py index aab7930..d685718 100644 --- a/tests/test_calculations.py +++ b/tests/test_calculations.py @@ -4,7 +4,7 @@ from decimal import Decimal import pytest from grid_trading.domain.calculations import CalculationError, calculate_positions, estimate_fees -from grid_trading.domain.models import FeeRules, Instrument, Trade, TradeGroup, TradeSide +from grid_trading.domain.models import FeeRules, Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide def make_trade( @@ -111,6 +111,36 @@ def test_t_plus_one_available_quantity_excludes_today_buys(): assert summary.available_quantity == 200 +def test_quote_snapshot_overrides_manual_price_for_position_value(): + today = date(2026, 7, 8) + instrument = Instrument(id=1, code="510300", name="沪深300ETF", manual_price=Decimal("3.90")) + trades = [ + make_trade( + trade_id=1, + trade_date=today - timedelta(days=1), + side=TradeSide.BUY, + price="4.00", + quantity=1000, + trade_group=TradeGroup.BASE, + ) + ] + quotes = { + 1: QuoteSnapshot( + symbol="sh510300", + code="510300", + name="沪深300ETF", + price=Decimal("4.12"), + source="tencent", + ) + } + + [summary] = calculate_positions([instrument], trades, as_of=today, quote_snapshots=quotes) + + assert summary.current_price == Decimal("4.12") + assert summary.price_source == "tencent" + assert summary.market_value == Decimal("4120.00") + + def test_sell_more_than_group_position_raises(): today = date(2026, 7, 8) instrument = Instrument(id=1, code="159915", name="创业板ETF") diff --git a/tests/test_services.py b/tests/test_services.py index 2bf4aab..05e4f10 100644 --- a/tests/test_services.py +++ b/tests/test_services.py @@ -3,10 +3,24 @@ from decimal import Decimal import pytest -from grid_trading.domain.models import Instrument, Trade, TradeGroup, TradeSide +from grid_trading.domain.models import Instrument, QuoteSnapshot, Trade, TradeGroup, TradeSide from grid_trading.services.trading_service import TradingService +class FakeQuoteProvider: + def fetch_quotes(self, instruments): + return { + "510300": QuoteSnapshot( + symbol="sh510300", + code="510300", + name="沪深300ETF", + price=Decimal("4.12"), + source="tencent", + quote_time="20260708150000", + ) + } + + def test_service_creates_default_account_and_computes_summary(tmp_path): service = TradingService(tmp_path / "grid.db") service.ensure_defaults() @@ -177,3 +191,33 @@ def test_service_rejects_historical_changes_that_break_future_sells(tmp_path): with pytest.raises(ValueError, match="后续成交"): service.delete_trade(buy.id) + + +def test_service_refresh_quotes_uses_realtime_price_in_summaries(tmp_path): + service = TradingService(tmp_path / "grid.db", quote_provider=FakeQuoteProvider()) + service.ensure_defaults() + account = service.get_active_account() + instrument = service.add_instrument( + Instrument(id=None, code="510300", name="沪深300ETF", market="ETF", manual_price=Decimal("3.90")) + ) + service.save_trade( + Trade( + id=None, + account_id=account.id, + instrument_id=instrument.id, + trade_date=date(2026, 7, 7), + side=TradeSide.BUY, + price=Decimal("4.00"), + quantity=1000, + trade_group=TradeGroup.BASE, + ) + ) + + quotes = service.refresh_quotes() + [position] = service.get_position_summaries(as_of=date(2026, 7, 8)) + summary = service.get_account_summary(as_of=date(2026, 7, 8)) + + assert quotes["510300"].price == Decimal("4.12") + assert position.current_price == Decimal("4.12") + assert position.price_source == "tencent" + assert summary.market_value == Decimal("4120.00")