«Проведи глубокий интернет-ресерч по тому, как индустрия структурирует данные о ставках фрахта, и на основе устоявшихся паттернов предложи минимум три варианта protobuf-контракта FreightRate, один финальный рекомендуемый proto3-контракт, оптимальный набор gRPC RPC, enum’ы, связь с Source/run_pipeline, историю и пример mapping/output_schema для Black Sea grain freight. Цитируй Baltic, Worldscale, Platts, Argus, Freightos, SeaRates, Fearnleys и др.; отделяй индустриальный паттерн от архитектурного решения».
Отчёт опирается на 24 нормализованные строки доказательной базы с 24 различных хостов (ни один хост не даёт больше 4,2% строк); первичные методологии — Baltic FBX Guide, Platts Specifications Guide (апрель 2026), Argus Tanker Freight, документация SeaRates и Freightos — отделены от вторичных описаний. Дата ресерча: 2026-10-05. Главный deliverable — компилируемый proto3-контракт freight.v1 ниже.
1. Ставка — не число. Хранить фрахт как from/to/vessel/rate нельзя: один и тот же number неинтерпретируем без rate_basis, rate_unit, currency, времени оценки, спецификации маршрута/судна/груза и provenance. Baltic котирует dry как USD/day или USD/mt, танкеры как WS и TCE, FBX — за 40-футовый контейнер; Platts/Argus дополнительно нормализуют cargo size, laycan и bunker assumptions.
2. Единый envelope, не четыре time series. Рекомендуется единый immutable observation envelope FreightRate плюс каталоги FreightRoute, FreightBenchmark, VesselClass; внутри envelope — общие измерения и oneof типизированных segment_details для dry/tanker/container/gas. Четыре независимых top-level серии раздробили бы List/latest/history; полностью бесформенный map потерял бы валидацию.
3. Базис и единица — разные оси. Валюта хранится отдельно от знаменателя (никаких USD_PER_MT). Primary и derived котировки раздельны: primary WS 155, эквивалент USD/mt и TCE USD/day идут в equivalent_quotes с is_derived + methodology_id и не подменяют исходную котировку.
4. C3/P7/TD3C/TC2/FBX01 — не enum. Это vendor benchmark identifiers со спецификацией маршрута, судна, груза, единицы и методологии. Хранить benchmark_id + benchmark_code + route_id; composite-индексы (BDI, FBX global) связаны с component benchmark IDs. IndexName — расширяемый каталог FreightBenchmark, не enum.
5. «Текущая ставка» — read projection. GetFreightMarketQuote выбирает последнее подходящее immutable наблюдение и возвращает as_of/age/stale; не новая сущность и никогда не персистится. ListFreightRatesByRoute не нужен: это ListFreightRates(filter.route_ids); отдельный RPC создал бы две семантики пагинации.
Прозрачно о пробелах. В доказательной базе не найдено достаточно конкретной публично верифицируемой схемы rate-записи для Fearnleys Hasura, Shipfinex или Yieldchaser — их endpoints не выдумываются. Climatiq — прежде всего расчёт freight-эмиссий, смежное свидетельство, не основа для схемы ставок. SeaRates Get Rates — данные котировок перевозчика (validity, itemized tariffs), семантически иное, чем PRA-assessment.
Нумерация полей: 1–15 горячие, 16–99 реже используемые, 100+ provenance/metadata. Decimal — строка в канонической base-10 форме (заменима платформенным Decimal без смены семантики); message-поля в proto3 уже имеют presence, optional используется для nullable scalar/enum/string. Сервисная валидация требует primary_quote, rate_basis, assessed_at, source_id, observed_at.
assessed_at — момент рыночной оценки/публикации (Platts London 16:30 и т.п.);observed_at — момент получения записи пайплайном (ingestion), другая ось;valid_from/valid_to — срок действия котировки (ключевая ось carrier quote у SeaRates);laycan_start/laycan_end — окно погрузки оцениваемого рейса (Platts: MR 7–15 дней вперёд, VLCC 10–25).syntax = "proto3";
package freight.v1;
import "google/api/annotations.proto";
import "google/protobuf/empty.proto";
import "google/protobuf/field_mask.proto";
import "google/protobuf/timestamp.proto";
// Decimal передаётся строкой в канонической base-10 форме ("12.345"):
// в protobuf нет native decimal, а double/float недопустимы для денег.
// Строку можно заменить платформенным сообщением Decimal без смены семантики.
// ---------- Enums (все *_UNSPECIFIED = 0, шаг 10) ----------
enum ShippingSegment {
SHIPPING_SEGMENT_UNSPECIFIED = 0;
SHIPPING_SEGMENT_DRY_BULK = 10;
SHIPPING_SEGMENT_TANKER = 20;
SHIPPING_SEGMENT_CONTAINER = 30;
SHIPPING_SEGMENT_GAS = 40;
SHIPPING_SEGMENT_ROAD = 50;
SHIPPING_SEGMENT_RAIL = 60;
SHIPPING_SEGMENT_AIR = 70;
SHIPPING_SEGMENT_MULTIMODAL = 80;
}
enum RateBasis {
RATE_BASIS_UNSPECIFIED = 0;
RATE_BASIS_VOYAGE = 10;
RATE_BASIS_TIME_CHARTER = 20;
RATE_BASIS_WORLD_SCALE = 30;
RATE_BASIS_TIME_CHARTER_EQUIVALENT = 40;
RATE_BASIS_LUMPSUM = 50;
RATE_BASIS_CONTAINER = 60;
RATE_BASIS_INDEX = 70;
RATE_BASIS_DEMURRAGE = 80;
RATE_BASIS_CONTRACT = 90;
}
// Валюта хранится отдельно (QuoteValue.currency): никаких USD_PER_MT.
enum RateUnit {
RATE_UNIT_UNSPECIFIED = 0;
RATE_UNIT_PER_METRIC_TONNE = 10;
RATE_UNIT_PER_DAY = 20;
RATE_UNIT_WORLD_SCALE_POINT = 30;
RATE_UNIT_PER_CONTAINER = 40;
RATE_UNIT_PER_TEU = 50;
RATE_UNIT_PER_FEU = 60;
RATE_UNIT_PER_CUBIC_METRE = 70;
RATE_UNIT_PER_REVENUE_TONNE = 80;
RATE_UNIT_PER_BARREL = 90;
RATE_UNIT_LUMPSUM = 100;
RATE_UNIT_INDEX_POINT = 110;
RATE_UNIT_PERCENT = 120;
RATE_UNIT_PER_KILOMETRE = 130;
RATE_UNIT_PER_TRIP = 140;
}
enum MarketType {
MARKET_TYPE_UNSPECIFIED = 0;
MARKET_TYPE_SPOT = 10;
MARKET_TYPE_TERM = 20;
MARKET_TYPE_INDEX = 30;
MARKET_TYPE_TIME_CHARTER_AVERAGE = 40;
MARKET_TYPE_FORWARD = 50;
MARKET_TYPE_CONTRACT = 60;
MARKET_TYPE_INDICATIVE = 70;
MARKET_TYPE_TRANSACTIONAL = 80;
}
enum ObservationKind {
OBSERVATION_KIND_UNSPECIFIED = 0;
OBSERVATION_KIND_ASSESSMENT = 10; // оценка PRA (Platts, Argus, Baltic)
OBSERVATION_KIND_INDEX_VALUE = 20; // значение композитного индекса
OBSERVATION_KIND_CARRIER_QUOTE = 30; // котировка перевозчика (SeaRates)
OBSERVATION_KIND_FIXTURE = 40; // фактическая сделка
OBSERVATION_KIND_DERIVED_VALUE = 50; // производное значение (TCE и т.п.)
}
// Портируемый класс судна для обмена; диапазоны DWT пересекаются и
// меняются во времени — авторитетны vessel_class_id и VesselSpecification.
enum VesselType {
VESSEL_TYPE_UNSPECIFIED = 0;
VESSEL_TYPE_HANDYSIZE = 110;
VESSEL_TYPE_SUPRAMAX = 120;
VESSEL_TYPE_ULTRAMAX = 130;
VESSEL_TYPE_PANAMAX = 140;
VESSEL_TYPE_KAMSARMAX = 150;
VESSEL_TYPE_CAPESIZE = 160;
VESSEL_TYPE_NEWCASTLEMAX = 170;
VESSEL_TYPE_TANKER_HANDY = 210;
VESSEL_TYPE_MR = 220;
VESSEL_TYPE_LR1 = 230;
VESSEL_TYPE_LR2 = 240;
VESSEL_TYPE_AFRAMAX = 250;
VESSEL_TYPE_SUEZMAX = 260;
VESSEL_TYPE_VLCC = 270;
VESSEL_TYPE_ULCC = 280;
VESSEL_TYPE_CONTAINER_FEEDER = 310;
VESSEL_TYPE_CONTAINER_PANAMAX = 320;
VESSEL_TYPE_CONTAINER_POST_PANAMAX = 330;
VESSEL_TYPE_CONTAINER_NEW_PANAMAX = 340;
VESSEL_TYPE_ULTRA_LARGE_CONTAINER = 350;
VESSEL_TYPE_LNG_CARRIER = 410;
VESSEL_TYPE_MGC = 420;
VESSEL_TYPE_VLGC = 430;
}
// Incoterm — не базис фрахта: он описывает покрытие котировки и
// обязательства сторон; хранится вместе с named place и версией.
enum Incoterm {
INCOTERM_UNSPECIFIED = 0;
INCOTERM_EXW = 10;
INCOTERM_FCA = 20;
INCOTERM_CPT = 30;
INCOTERM_CIP = 40;
INCOTERM_DAP = 50;
INCOTERM_DPU = 60;
INCOTERM_DDP = 70;
INCOTERM_FAS = 80;
INCOTERM_FOB = 90;
INCOTERM_CFR = 100;
INCOTERM_CIF = 110;
}
enum ContainerType {
CONTAINER_TYPE_UNSPECIFIED = 0;
CONTAINER_TYPE_DRY_20 = 10;
CONTAINER_TYPE_DRY_40 = 20;
CONTAINER_TYPE_HIGH_CUBE_40 = 30;
CONTAINER_TYPE_REEFER_20 = 40;
CONTAINER_TYPE_REEFER_40 = 50;
CONTAINER_TYPE_OTHER = 60;
}
enum ServiceScope {
SERVICE_SCOPE_UNSPECIFIED = 0;
SERVICE_SCOPE_PORT_TO_PORT = 10;
SERVICE_SCOPE_DOOR_TO_DOOR = 20;
SERVICE_SCOPE_DOOR_TO_PORT = 30;
SERVICE_SCOPE_PORT_TO_DOOR = 40;
}
enum TankerCargoType {
TANKER_CARGO_TYPE_UNSPECIFIED = 0;
TANKER_CARGO_TYPE_CLEAN = 10;
TANKER_CARGO_TYPE_DIRTY = 20;
}
enum GasCargoType {
GAS_CARGO_TYPE_UNSPECIFIED = 0;
GAS_CARGO_TYPE_LNG = 10;
GAS_CARGO_TYPE_LPG = 20;
}
enum RouteType {
ROUTE_TYPE_UNSPECIFIED = 0;
ROUTE_TYPE_ONE_WAY = 10;
ROUTE_TYPE_ROUND_VOYAGE = 20;
ROUTE_TYPE_AREA_TO_AREA = 30;
ROUTE_TYPE_COMPOSITE_LANE = 40;
}
enum BenchmarkKind {
BENCHMARK_KIND_UNSPECIFIED = 0;
BENCHMARK_KIND_ROUTE_ASSESSMENT = 10; // C3, TD3C, TC2, FBX01
BENCHMARK_KIND_TIME_CHARTER_AVERAGE = 20; // 5TC averages
BENCHMARK_KIND_COMPOSITE_INDEX = 30; // BDI, FBX global
BENCHMARK_KIND_CARRIER_QUOTE_SERIES = 40; // серии котировок перевозчика
}
enum QuantityUnit {
QUANTITY_UNIT_UNSPECIFIED = 0;
QUANTITY_UNIT_METRIC_TONNE = 10;
QUANTITY_UNIT_CUBIC_METRE = 20;
QUANTITY_UNIT_BARREL = 30;
QUANTITY_UNIT_TEU = 40;
QUANTITY_UNIT_FEU = 50;
}
// ---------- Value objects ----------
message QuoteValue {
string amount = 1; // канонический decimal, напр. "25.75"
RateUnit unit = 2;
optional string currency = 3; // ISO-4217; отсутствует для WS/index points
optional bool is_derived = 4; // TCE, USD/mt-эквивалент WS и т.п.
optional string methodology_id = 5; // обязателен для derived значений
optional string label = 6; // "TCE", "USD/mt equivalent"
optional uint32 flat_rate_year = 7; // версия Worldscale flat rate
optional string raw_value = 8; // исходный текст источника
}
message VesselSpecification {
VesselType vessel_type = 1;
optional string vessel_class_id = 2; // ссылка на каталог/таксономию
optional uint64 deadweight_tonnes = 3;
optional uint64 capacity_cubic_metres = 4;
optional uint64 capacity_teu = 5;
optional uint32 max_age_years = 6;
optional bool scrubber_fitted = 7;
optional bool eco_design = 8;
optional string bunker_basis = 9; // "0.5% S non-eco" и т.п.
optional string raw_description = 10;
}
message CargoSpecification {
optional string commodity_id = 1;
optional string amount = 2; // decimal; размер лота
optional QuantityUnit unit = 3;
optional string tolerance_percent = 4; // напр. "10" для 10% MOLOO
optional string grade = 5;
optional string raw_description = 6;
}
// ---------- Типизированные расширения по сегменту ----------
message DryBulkDetails {
optional bool is_round_voyage = 1;
optional string delivery_area = 2;
optional string redelivery_area = 3;
}
message TankerDetails {
TankerCargoType cargo_type = 1; // CLEAN / DIRTY
optional uint32 worldscale_flat_rate_year = 2;
optional string worldscale_flat_rate_usd_per_mt = 3; // decimal
optional string commission_percent = 4; // decimal
optional string bunker_price_basis = 5;
}
message ChargeComponent {
string code = 1; // "OCEAN", "BAF", "THC_ORIGIN"...
string amount = 2; // decimal
RateUnit unit = 3;
optional string currency = 4;
optional bool included_in_primary_quote = 5;
}
message ContainerDetails {
ContainerType container_type = 1;
ServiceScope service_scope = 2;
optional bool freight_all_kinds = 3; // FAK
optional bool includes_origin_charges = 4;
optional bool includes_destination_charges = 5;
optional bool includes_seaborne_surcharges = 6; // модель FBX: CY-CY all-in
repeated ChargeComponent charge_components = 7;
}
message GasDetails {
GasCargoType cargo_type = 1; // LNG / LPG
optional uint64 carrier_capacity_cubic_metres = 2;
optional string fuel_basis = 3;
}
// ---------- FreightRate: immutable observation envelope ----------
// Нумерация: 1-15 горячие поля, 16-99 реже используемые, 100+ provenance.
// Сервис при записи требует: primary_quote, rate_basis, assessed_at,
// source_id, observed_at (proto3 не умеет required — валидация в сервисе).
message FreightRate {
string freight_rate_id = 1;
string route_id = 2; // канонический маршрут каталога
optional string origin_location_id = 3;
optional string destination_location_id = 4;
optional string route_code = 5; // денормализованный код источника
optional ShippingSegment segment = 6;
optional VesselType vessel_type = 7; // портируемый класс; точный —
optional string vessel_class_id = 8; // vessel_class_id + vessel_spec
optional string commodity_id = 9;
QuoteValue primary_quote = 10; // исходная котировка как есть
RateBasis rate_basis = 11;
MarketType market_type = 12;
ObservationKind observation_kind = 13;
google.protobuf.Timestamp assessed_at = 14; // момент оценки/публикации рынка
optional string benchmark_id = 15;
VesselSpecification vessel_spec = 16;
CargoSpecification cargo_spec = 17;
repeated QuoteValue equivalent_quotes = 18; // derived: TCE, USD/mt от WS
optional Incoterm incoterm = 19;
optional string incoterm_named_place = 20;
optional uint32 incoterms_version = 21; // напр. 2020
google.protobuf.Timestamp laycan_start = 22;
google.protobuf.Timestamp laycan_end = 23;
google.protobuf.Timestamp valid_from = 24; // срок действия котировки
google.protobuf.Timestamp valid_to = 25;
optional string benchmark_code = 26; // "C3", "TD3C", "FBX01"
optional string methodology_id = 27;
oneof segment_details {
DryBulkDetails dry_bulk_details = 28;
TankerDetails tanker_details = 29;
ContainerDetails container_details = 30;
GasDetails gas_details = 31;
}
string source_id = 100;
string source_url = 101;
string language = 102;
google.protobuf.Timestamp observed_at = 103; // момент получения пайплайном
optional float confidence = 104; // [0,1]
uint32 schema_version = 105;
optional string external_record_id = 106;
map<string, string> metadata = 199; // raw labels для аудита
}
// ---------- Каталоги ----------
message RouteLeg {
uint32 sequence = 1;
string origin_location_id = 2;
string destination_location_id = 3;
optional string via = 4;
}
message FreightRoute {
string route_id = 1;
optional string code = 2; // не глобально уникален без namespace
string name = 3;
RouteType route_type = 4;
optional string origin_location_id = 5;
optional string destination_location_id = 6;
repeated RouteLeg legs = 7;
optional ShippingSegment segment = 8;
optional string default_vessel_class_id = 9;
optional string default_commodity_id = 10;
optional CargoSpecification default_cargo_size = 11;
google.protobuf.Timestamp valid_from = 12;
google.protobuf.Timestamp valid_to = 13;
optional string publisher_namespace = 14; // "baltic", "freightos"...
string source_id = 100;
string source_url = 101;
map<string, string> metadata = 199;
}
// Каталог вместо enum IndexName: C3 и FBX01 — строки-бенчмарки,
// BDI/BCI/FBX global — composite-строки с component_benchmark_ids.
message FreightBenchmark {
string benchmark_id = 1;
string code = 2; // "C3", "TD3C", "FBX01"
string name = 3;
string publisher = 4; // "Baltic Exchange", "S&P Global"...
BenchmarkKind kind = 5;
optional ShippingSegment segment = 6;
optional string route_id = 7;
optional string vessel_class_id = 8;
RateBasis rate_basis = 9;
RateUnit rate_unit = 10;
optional string currency = 11;
repeated string component_benchmark_ids = 12; // для композитов
optional string methodology_url = 13;
google.protobuf.Timestamp valid_from = 14; // effective dating спецификации
google.protobuf.Timestamp valid_to = 15;
string source_id = 100;
string source_url = 101;
map<string, string> metadata = 199;
}
// Если таксономией судов владеет этот сервис (иначе — reuse внешней):
message VesselClass {
string vessel_class_id = 1;
string name = 2; // "Capesize 180k (Baltic 2014+)"
ShippingSegment segment = 3;
optional uint64 min_deadweight_tonnes = 4;
optional uint64 max_deadweight_tonnes = 5;
optional uint64 reference_deadweight_tonnes = 6;
optional uint64 reference_capacity_cubic_metres = 7;
optional uint32 reference_capacity_teu = 8;
google.protobuf.Timestamp effective_from = 9;
google.protobuf.Timestamp effective_to = 10;
string source_id = 100;
string source_url = 101;
map<string, string> metadata = 199;
}
// ---------- Requests / Responses ----------
message GetFreightRateRequest { string freight_rate_id = 1; }
message FreightRateFilter {
repeated string route_ids = 1;
repeated string origin_location_ids = 2;
repeated string destination_location_ids = 3;
repeated string route_codes = 4;
repeated VesselType vessel_types = 5;
repeated string vessel_class_ids = 6;
repeated string commodity_ids = 7;
repeated string benchmark_ids = 8;
repeated string benchmark_codes = 9;
repeated RateBasis rate_bases = 10;
repeated RateUnit rate_units = 11;
repeated MarketType market_types = 12;
repeated ObservationKind observation_kinds = 13;
repeated string source_ids = 14;
google.protobuf.Timestamp assessed_from = 15;
google.protobuf.Timestamp assessed_to = 16;
google.protobuf.Timestamp observed_from = 17;
google.protobuf.Timestamp observed_to = 18;
// Требует детерминированный series key и tie-break:
// сначала assessed_at, затем observed_at.
optional bool latest_per_series = 19;
}
message ListFreightRatesRequest {
FreightRateFilter filter = 1;
int32 page_size = 2;
string page_token = 3;
string order_by = 4; // напр. "assessed_at desc"
}
message ListFreightRatesResponse {
repeated FreightRate freight_rates = 1;
string next_page_token = 2;
}
message CreateFreightRateRequest { FreightRate freight_rate = 1; }
message UpdateFreightRateRequest {
FreightRate freight_rate = 1;
google.protobuf.FieldMask update_mask = 2;
}
message DeleteFreightRateRequest { string freight_rate_id = 1; }
message BatchGetFreightRatesRequest { repeated string freight_rate_ids = 1; }
message BatchGetFreightRatesResponse { repeated FreightRate freight_rates = 1; }
// Read projection поверх append-only наблюдений; не новая сущность.
message GetFreightMarketQuoteRequest {
// route_id либо origin+destination — ровно один способ адресации.
optional string route_id = 1;
optional string origin_location_id = 2;
optional string destination_location_id = 3;
optional VesselType vessel_type = 4;
optional string vessel_class_id = 5;
optional string commodity_id = 6;
optional string benchmark_id = 7;
optional RateBasis rate_basis = 8;
optional RateUnit preferred_unit = 9;
optional string preferred_currency = 10;
optional google.protobuf.Timestamp at_or_before = 11;
optional string max_age = 12; // ISO-8601 duration, напр. "P7D"
}
message FreightMarketQuote {
FreightRate rate = 1; // выбранное immutable наблюдение
google.protobuf.Timestamp as_of = 2;
string age = 3; // ISO-8601 duration
bool stale = 4; // age > max_age
optional string selection_reason = 5;
}
message GetLatestRateRequest { FreightRateFilter filter = 1; }
message GetFreightRouteRequest { string route_id = 1; }
message ListFreightRoutesRequest {
repeated ShippingSegment segments = 1;
repeated RouteType route_types = 2;
repeated string publisher_namespaces = 3;
repeated string origin_location_ids = 4;
repeated string destination_location_ids = 5;
int32 page_size = 6;
string page_token = 7;
}
message ListFreightRoutesResponse {
repeated FreightRoute freight_routes = 1;
string next_page_token = 2;
}
message CreateFreightRouteRequest { FreightRoute freight_route = 1; }
message UpdateFreightRouteRequest {
FreightRoute freight_route = 1;
google.protobuf.FieldMask update_mask = 2;
}
message DeleteFreightRouteRequest { string route_id = 1; }
message GetFreightBenchmarkRequest { string benchmark_id = 1; }
message ListFreightBenchmarksRequest {
repeated BenchmarkKind kinds = 1;
repeated ShippingSegment segments = 2;
repeated string publishers = 3;
repeated string route_ids = 4;
int32 page_size = 5;
string page_token = 6;
}
message ListFreightBenchmarksResponse {
repeated FreightBenchmark freight_benchmarks = 1;
string next_page_token = 2;
}
message CreateFreightBenchmarkRequest { FreightBenchmark freight_benchmark = 1; }
message UpdateFreightBenchmarkRequest {
FreightBenchmark freight_benchmark = 1;
google.protobuf.FieldMask update_mask = 2;
}
message DeleteFreightBenchmarkRequest { string benchmark_id = 1; }
// ---------- Service ----------
service FreightService {
rpc GetFreightRate(GetFreightRateRequest) returns (FreightRate) {
option (google.api.http) = { get: "/v1/freight-rates/{freight_rate_id}" };
}
// ListFreightRatesByRoute не нужен: это filter.route_ids —
// второй RPC создал бы две семантики пагинации.
rpc ListFreightRates(ListFreightRatesRequest) returns (ListFreightRatesResponse) {
option (google.api.http) = { get: "/v1/freight-rates" };
}
// Create — append-only для наблюдений.
rpc CreateFreightRate(CreateFreightRateRequest) returns (FreightRate) {
option (google.api.http) = { post: "/v1/freight-rates" body: "freight_rate" };
}
// Update правит только metadata/provenance по политике,
// либо создаёт superseding revision.
rpc UpdateFreightRate(UpdateFreightRateRequest) returns (FreightRate) {
option (google.api.http) = {
patch: "/v1/freight-rates/{freight_rate.freight_rate_id}"
body: "freight_rate"
};
}
// Delete — tombstone/admin-операция, не бизнес-поток.
rpc DeleteFreightRate(DeleteFreightRateRequest) returns (google.protobuf.Empty) {
option (google.api.http) = { delete: "/v1/freight-rates/{freight_rate_id}" };
}
rpc BatchGetFreightRates(BatchGetFreightRatesRequest) returns (BatchGetFreightRatesResponse) {
option (google.api.http) = { get: "/v1/freight-rates:batchGet" };
}
// Основной публичный read-model RPC: возвращает последнее подходящее
// immutable наблюдение + as_of/staleness; никогда не персистится.
rpc GetFreightMarketQuote(GetFreightMarketQuoteRequest) returns (FreightMarketQuote) {
option (google.api.http) = { get: "/v1/freight-market-quote" };
}
// Простое convenience-API поверх того же выбора.
rpc GetLatestRate(GetLatestRateRequest) returns (FreightRate) {
option (google.api.http) = { get: "/v1/freight-rates:latest" };
}
rpc GetFreightRoute(GetFreightRouteRequest) returns (FreightRoute) {
option (google.api.http) = { get: "/v1/freight-routes/{route_id}" };
}
rpc ListFreightRoutes(ListFreightRoutesRequest) returns (ListFreightRoutesResponse) {
option (google.api.http) = { get: "/v1/freight-routes" };
}
rpc CreateFreightRoute(CreateFreightRouteRequest) returns (FreightRoute) {
option (google.api.http) = { post: "/v1/freight-routes" body: "freight_route" };
}
rpc UpdateFreightRoute(UpdateFreightRouteRequest) returns (FreightRoute) {
option (google.api.http) = {
patch: "/v1/freight-routes/{freight_route.route_id}"
body: "freight_route"
};
}
rpc DeleteFreightRoute(DeleteFreightRouteRequest) returns (google.protobuf.Empty) {
option (google.api.http) = { delete: "/v1/freight-routes/{route_id}" };
}
rpc GetFreightBenchmark(GetFreightBenchmarkRequest) returns (FreightBenchmark) {
option (google.api.http) = { get: "/v1/freight-benchmarks/{benchmark_id}" };
}
// ListFreightIndexes для UI — alias поверх этого List с
// kinds in (TIME_CHARTER_AVERAGE, COMPOSITE_INDEX); не второй source of truth.
rpc ListFreightBenchmarks(ListFreightBenchmarksRequest) returns (ListFreightBenchmarksResponse) {
option (google.api.http) = { get: "/v1/freight-benchmarks" };
}
rpc CreateFreightBenchmark(CreateFreightBenchmarkRequest) returns (FreightBenchmark) {
option (google.api.http) = { post: "/v1/freight-benchmarks" body: "freight_benchmark" };
}
rpc UpdateFreightBenchmark(UpdateFreightBenchmarkRequest) returns (FreightBenchmark) {
option (google.api.http) = {
patch: "/v1/freight-benchmarks/{freight_benchmark.benchmark_id}"
body: "freight_benchmark"
};
}
rpc DeleteFreightBenchmark(DeleteFreightBenchmarkRequest) returns (google.protobuf.Empty) {
option (google.api.http) = { delete: "/v1/freight-benchmarks/{benchmark_id}" };
}
}
Состав: id, from/to, vessel_type, commodity_id, amount, unit, currency, basis, incoterm, assessed_at + provenance.
Валидация: слабая — семантические коллизии неизбежны. Аналитика: простые, но неточные проекции. Эволюция: быстрый MVP, потом болезненный рефакторинг. Стоимость: минимальная.
Вердикт: только raw staging, не канонический verified store. Даже в A нельзя опустить rate_basis, rate_unit, assessed_at, source_id/source_url, observed_at; желательно route_id/code и raw vessel label.
Состав: route_id/code, vessel class/spec, cargo/spec, primary/equivalent quote, basis, market, benchmark, laycan/validity, provenance.
Валидация: хорошая по общим осям; сегментные поля (tanker/container) дрейфуют в metadata. Аналитика: отличные list/history по dry voyage и TC. Эволюция: плавная — добавление typed details. Стоимость: средняя.
Вердикт: жизнеспособное MVP-ядро канонической модели.
Состав: DryBulkRate / TankerRate / ContainerRate / GasRate как top-level, либо oneof.
Валидация: сильнейшая по сегменту и лучшая discoverability. Аналитика: cross-segment трудна. Эволюция: миграции, когда продукты пересекают границы сегментов. Стоимость: высокая — дублированные CRUD/history/filter/pagination.
Вердикт: не дробить top-level сервисы. Итог — гибрид: единый envelope + oneof details (рекомендуемый контракт выше).
GET /v1/freight-rates/{freight_rate_id}GET /v1/freight-ratesPOST /v1/freight-ratesPATCH /v1/freight-rates/{…} + update_maskDELETE /v1/freight-rates/{…}GET /v1/freight-rates:batchGetGET /v1/freight-market-quoteGET /v1/freight-rates:latest/v1/freight-routes/v1/freight-benchmarksНамеренно отсутствует: ListFreightRatesByRoute — дублировал бы ListFreightRates и создал бы две семантики пагинации.
Все enum имеют *_UNSPECIFIED = 0, значения с шагом 10, имена с префиксом против коллизий на уровне package. Полные определения — в proto выше; здесь семантические пояснения.
primary_quote.amount парсится как decimal; currency обязательна для денежных единиц и запрещена/опциональна для WS/index points.Source остаётся чёрным ящиком. Output kind: канонически FREIGHT_RATE наружу, legacy route_cost маппится внутренне. Source Targets объявляют целевую сущность FreightRate и ссылки на словари нормализации: route/location, vessel class, commodity, benchmark, unit/currency. Source defaults могут задавать currency/unit только как проверенный инвариант серии; пропущенный rate basis никогда не доинферивается молча.
Стадии: raw extraction → typed staging record c сохранением raw labels → dictionary resolution → semantic validation → run_pipeline sample verification/coercion → immutable append. external_record_id и raw_value/raw labels сохраняются в metadata для аудита.
origin_location_raw string required destination_location_raw string required route_code_raw string optional vessel_class_raw string required vessel_size_dwt uint64 optional commodity_raw string default GRAIN — только если source target это фиксирует cargo_size_mt decimal optional rate_amount decimal required, > 0 currency string required, ISO-4217, ожидается USD rate_unit enum ожидается PER_METRIC_TONNE rate_basis enum ожидается VOYAGE assessed_at timestamp required source_url uri required language string optional external_record_id string optional notes_raw string optional
origin_location_raw <- column/from destination_location_raw <- column/to vessel_class_raw <- column/vessel rate_amount <- parse_decimal(column/usd_mt) currency <- constant USD // только после верификации rate_unit <- constant PER_METRIC_TONNE rate_basis <- constant VOYAGE commodity_raw <- source target GRAIN assessed_at <- report date source_url <- page URL
Проекция разрешает route_id из origin/destination + publisher namespace; разрешает vessel_class_id, сохраняя label «Panamax»/«Handysize»; собирает primary_quote; прикрепляет provenance. Если таблица содержит диапазоны low/high — моделировать отдельные optional-поля или AssessmentRange, не усреднять без методологии источника (открытое расширение).
Первичные/официальные методологии помечены тёмной меткой; вторичные страницы использованы как подтверждение.
| Тип | Организация / продукт | Модель котировки (фрагмент) | Источник |
|---|
Метод: 24 нормализованные evidence-строки с 24 различных хостов (каждый хост ≤ 4,2% строк), собранные широким поисковым корпусом и сведённые в shortlist; дата ресерча 2026-10-05. Каждая строка описывает, как организация структурирует котировку фрахта: маршрут, судно/оборудование, груз, единицу и формулу/индекс. Фрагменты котировок укорочены для ширины; полные URL — в ссылках. Для Fearnleys Hasura, Shipfinex, Yieldchaser публично верифицируемая схема в shortlist не найдена; Climatiq — смежное свидетельство. Код proto — архитектурное предложение автора на основе этих паттернов, не цитата из источника.