Одна цифра ставки неинтерпретируема: фрахт требует envelope из котировки, базиса, единицы, спецификаций и provenance

Asked (summary):

«Проведи глубокий интернет-ресерч по тому, как индустрия структурирует данные о ставках фрахта, и на основе устоявшихся паттернов предложи минимум три варианта 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 ниже.

Наведите курсор на блок, чтобы увидеть ключевые поля и роль узла. Слева — конвейер данных, справа — состав envelope.
Baltic публикует dry bulk в USD/day и USD/mt, танкеры в Worldscale-пунктах и TCE, LNG в USD/day, LPG в USD/mt, контейнеры (FBX) за 40-футовый контейнер — одна сущность с полями basis/unit обязана покрывать всё это. ftmercati.com
FBX — median all-in CY–CY ставка за FEU по 12 tradelane с включённостями/исключениями и взвешиванием по объёму перевозчиков: котировка без charge-inclusions неполна. balticexchange.com
Platts нормализует оценку к cargo size, laycan, bunker basis и стандартному судну и публикует WS-пункты, $/mt, lumpsum и TCE $/day как связанные, но разные величины — отсюда primary_quote + equivalent_quotes c is_derived. spglobal.com
Argus даёт явную формулу TCE и конвертацию WS→$/t через региональную корзину flat rates: WS-пункт без версии flat rate непереводим в $/mt, поэтому flat_rate_year — поле контракта. argusmedia.com

Executive summary

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.

Финальный рекомендуемый контракт — freight.v1, компилируемый proto3

Нумерация полей: 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.

Семантика временных осей

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}" };
  }
}

Три запрошенных варианта контракта

A — минимальный плоский

Состав: 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.

B — нормализованный generic

Состав: 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-ядро канонической модели.

C — отдельные контракты по сегментам

Состав: DryBulkRate / TankerRate / ContainerRate / GasRate как top-level, либо oneof.

Валидация: сильнейшая по сегменту и лучшая discoverability. Аналитика: cross-segment трудна. Эволюция: миграции, когда продукты пересекают границы сегментов. Стоимость: высокая — дублированные CRUD/history/filter/pagination.

Вердикт: не дробить top-level сервисы. Итог — гибрид: единый envelope + oneof details (рекомендуемый контракт выше).

Набор RPC

GetFreightRate
GET /v1/freight-rates/{freight_rate_id}
одно наблюдение по ID
ListFreightRates
GET /v1/freight-rates
единственный листинг: фильтр по route/benchmark/vessel/basis/unit/market/source + временные окна; latest_per_series требует детерминированный series key и tie-break assessed_at→observed_at
CreateFreightRate
POST /v1/freight-rates
append-only запись наблюдения
UpdateFreightRate
PATCH /v1/freight-rates/{…} + update_mask
только metadata/provenance по политике, иначе superseding revision
DeleteFreightRate
DELETE /v1/freight-rates/{…}
tombstone/admin
BatchGetFreightRates
GET /v1/freight-rates:batchGet
опционально, для аналитики; не требуется для MVP
GetFreightMarketQuote
GET /v1/freight-market-quote
основной публичный read model: route_id или origin+destination, предпочтительные unit/currency, at_or_before, max_age → rate + as_of + age + stale + selection_reason
GetLatestRate
GET /v1/freight-rates:latest
простое convenience поверх того же выбора
CRUD FreightRoute
/v1/freight-routes
каталог маршрутов; ListFreightRoutes и есть discovery
CRUD FreightBenchmark
/v1/freight-benchmarks
каталог бенчмарков; фильтр по kind/segment/publisher/route; ListFreightIndexes для UI — alias поверх kinds in (TIME_CHARTER_AVERAGE, COMPOSITE_INDEX), не второй source of truth

Намеренно отсутствует: ListFreightRatesByRoute — дублировал бы ListFreightRates и создал бы две семантики пагинации.

Enum-справочник

Все enum имеют *_UNSPECIFIED = 0, значения с шагом 10, имена с префиксом против коллизий на уровне package. Полные определения — в proto выше; здесь семантические пояснения.

Валидация и инварианты

Pipeline: Source → mapping/output_schema → run_pipeline → append-only

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 для аудита.

Black Sea grain voyage — пример output_schema

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

Пример mapping (generic-селекторы, не точные колонки страницы)

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, не усреднять без методологии источника (открытое расширение).

Verification assertions

История и хранение

Открытые решения

Источники — 24 строки, 24 хоста

Первичные/официальные методологии помечены тёмной меткой; вторичные страницы использованы как подтверждение.

ТипОрганизация / продуктМодель котировки (фрагмент)Источник

Метод: 24 нормализованные evidence-строки с 24 различных хостов (каждый хост ≤ 4,2% строк), собранные широким поисковым корпусом и сведённые в shortlist; дата ресерча 2026-10-05. Каждая строка описывает, как организация структурирует котировку фрахта: маршрут, судно/оборудование, груз, единицу и формулу/индекс. Фрагменты котировок укорочены для ширины; полные URL — в ссылках. Для Fearnleys Hasura, Shipfinex, Yieldchaser публично верифицируемая схема в shortlist не найдена; Climatiq — смежное свидетельство. Код proto — архитектурное предложение автора на основе этих паттернов, не цитата из источника.

This report was generated automatically by Keenable SELECT at a user's request, from publicly available web sources linked herein. Keenable does not review, verify, or endorse its contents and makes no representation as to accuracy, completeness, or timeliness; AI-based extraction may contain errors. Nothing in this report is investment, legal, financial, or other professional advice. All trademarks and referenced content remain the property of their respective owners; no affiliation or endorsement is implied. To report an error, rights concern, or request removal: legal@keenable.ai.

Keenable SELECTAsk your own question
Made with Keenable SELECT