feat: use realtime quotes in summaries
This commit is contained in:
@@ -14,6 +14,7 @@ from grid_trading.domain.models import (
|
|||||||
FeeRules,
|
FeeRules,
|
||||||
Instrument,
|
Instrument,
|
||||||
PositionSummary,
|
PositionSummary,
|
||||||
|
QuoteSnapshot,
|
||||||
Trade,
|
Trade,
|
||||||
TradeGroup,
|
TradeGroup,
|
||||||
TradeSide,
|
TradeSide,
|
||||||
@@ -65,8 +66,10 @@ def calculate_positions(
|
|||||||
*,
|
*,
|
||||||
as_of: date,
|
as_of: date,
|
||||||
instrument_cashflows: Mapping[int, Decimal] | None = None,
|
instrument_cashflows: Mapping[int, Decimal] | None = None,
|
||||||
|
quote_snapshots: Mapping[int, QuoteSnapshot] | None = None,
|
||||||
) -> list[PositionSummary]:
|
) -> list[PositionSummary]:
|
||||||
cashflows = instrument_cashflows or {}
|
cashflows = instrument_cashflows or {}
|
||||||
|
quotes = quote_snapshots or {}
|
||||||
instrument_by_id = {instrument.id: instrument for instrument in instruments if instrument.id is not None}
|
instrument_by_id = {instrument.id: instrument for instrument in instruments if instrument.id is not None}
|
||||||
states: dict[int, dict[TradeGroup, _GroupState]] = defaultdict(
|
states: dict[int, dict[TradeGroup, _GroupState]] = defaultdict(
|
||||||
lambda: {group: _GroupState() for group in TradeGroup}
|
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")))
|
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")))
|
realized_pnl = money(sum((state.realized_pnl for state in group_states.values()), Decimal("0")))
|
||||||
grid_profit = money(group_states[TradeGroup.GRID].realized_pnl)
|
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
|
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
|
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])
|
available_quantity = max(0, total_quantity - today_buys[instrument.id])
|
||||||
@@ -203,7 +210,10 @@ def _validate_trade(trade: Trade) -> None:
|
|||||||
def _resolve_current_price(
|
def _resolve_current_price(
|
||||||
instrument: Instrument,
|
instrument: Instrument,
|
||||||
fallback_trade_price: Decimal | None,
|
fallback_trade_price: Decimal | None,
|
||||||
|
quote_snapshot: QuoteSnapshot | None,
|
||||||
) -> tuple[Decimal | None, str]:
|
) -> tuple[Decimal | None, str]:
|
||||||
|
if quote_snapshot is not None:
|
||||||
|
return quote_snapshot.price, quote_snapshot.source
|
||||||
if instrument.manual_price is not None:
|
if instrument.manual_price is not None:
|
||||||
return instrument.manual_price, "manual"
|
return instrument.manual_price, "manual"
|
||||||
if fallback_trade_price is not None:
|
if fallback_trade_price is not None:
|
||||||
|
|||||||
@@ -19,18 +19,22 @@ from grid_trading.domain.models import (
|
|||||||
FeeRules,
|
FeeRules,
|
||||||
Instrument,
|
Instrument,
|
||||||
PositionSummary,
|
PositionSummary,
|
||||||
|
QuoteSnapshot,
|
||||||
StrategyOverride,
|
StrategyOverride,
|
||||||
StrategyTemplate,
|
StrategyTemplate,
|
||||||
Trade,
|
Trade,
|
||||||
TradeSide,
|
TradeSide,
|
||||||
)
|
)
|
||||||
|
from grid_trading.market.tencent import TencentQuoteProvider
|
||||||
from grid_trading.storage.repositories import Repository
|
from grid_trading.storage.repositories import Repository
|
||||||
|
|
||||||
|
|
||||||
class TradingService:
|
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 = Repository(db_path)
|
||||||
self.repository.initialize()
|
self.repository.initialize()
|
||||||
|
self.quote_provider = quote_provider or TencentQuoteProvider()
|
||||||
|
self._quote_cache: dict[str, QuoteSnapshot] = {}
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.repository.close()
|
self.repository.close()
|
||||||
@@ -88,6 +92,12 @@ class TradingService:
|
|||||||
def list_instruments(self) -> list[Instrument]:
|
def list_instruments(self) -> list[Instrument]:
|
||||||
return self.repository.list_instruments()
|
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:
|
def get_default_strategy_template(self) -> StrategyTemplate:
|
||||||
self.ensure_defaults()
|
self.ensure_defaults()
|
||||||
template = self.repository.get_default_strategy_template()
|
template = self.repository.get_default_strategy_template()
|
||||||
@@ -168,6 +178,7 @@ class TradingService:
|
|||||||
trades,
|
trades,
|
||||||
as_of=as_of_date,
|
as_of=as_of_date,
|
||||||
instrument_cashflows=self._instrument_cashflows(account.id),
|
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:
|
def get_account_summary(self, *, as_of: date | None = None) -> AccountSummary:
|
||||||
@@ -247,3 +258,16 @@ class TradingService:
|
|||||||
if entry.instrument_id is not None:
|
if entry.instrument_id is not None:
|
||||||
totals[entry.instrument_id] += entry.amount
|
totals[entry.instrument_id] += entry.amount
|
||||||
return dict(totals)
|
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
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from decimal import Decimal
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from grid_trading.domain.calculations import CalculationError, calculate_positions, estimate_fees
|
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(
|
def make_trade(
|
||||||
@@ -111,6 +111,36 @@ def test_t_plus_one_available_quantity_excludes_today_buys():
|
|||||||
assert summary.available_quantity == 200
|
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():
|
def test_sell_more_than_group_position_raises():
|
||||||
today = date(2026, 7, 8)
|
today = date(2026, 7, 8)
|
||||||
instrument = Instrument(id=1, code="159915", name="创业板ETF")
|
instrument = Instrument(id=1, code="159915", name="创业板ETF")
|
||||||
|
|||||||
@@ -3,10 +3,24 @@ from decimal import Decimal
|
|||||||
|
|
||||||
import pytest
|
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
|
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):
|
def test_service_creates_default_account_and_computes_summary(tmp_path):
|
||||||
service = TradingService(tmp_path / "grid.db")
|
service = TradingService(tmp_path / "grid.db")
|
||||||
service.ensure_defaults()
|
service.ensure_defaults()
|
||||||
@@ -177,3 +191,33 @@ def test_service_rejects_historical_changes_that_break_future_sells(tmp_path):
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="后续成交"):
|
with pytest.raises(ValueError, match="后续成交"):
|
||||||
service.delete_trade(buy.id)
|
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")
|
||||||
|
|||||||
Reference in New Issue
Block a user